React and Redux Coding Standards and Ecosystem Handbook
A production-focused reference for React 18+, strict TypeScript, Redux Toolkit, RTK Query, routing, forms, styling, testing, and code quality in enterprise applications.
Table of Contents
- 1 Module 1: Core React Architecture and Standards
- 2 Module 2: State Management with Redux Toolkit
- 3 Module 3: Data Fetching and Side Effects
- 4 Module 4: The Standard Library Ecosystem
- 5 Module 5: Testing Standards
- 6 Module 6: Code Quality, Linting, and Naming Conventions
connect(), createStore, manual Redux switch reducers, and ad hoc fetch architecture are rejected by default.Module 1: Core React Architecture and Standards
Modern React applications are built from typed functional components, declarative data flow, and small feature-owned modules. The goal is predictable rendering, shallow learning curves, and code that remains changeable at scale.
Standard: all components are functions with explicit prop contracts. Use type or interface for props. Do not use class components, implicit props, or anonymous default exports.
- Define narrow prop contracts.
- Export named components.
- Keep rendering responsibility focused.
- Prefer composition over inheritance.
- Do not use class components.
- Do not use
anyfor props. - Do not create god components.
- Do not push ordinary composition into legacy HOCs.
import { memo } from 'react';
type User = {
id: string;
name: string;
email: string;
isActive: boolean;
};
interface UserCardProps {
user: User;
isSelected?: boolean;
onSelect: (userId: string) => void;
}
export function UserCard({
user,
isSelected = false,
onSelect,
}: UserCardProps) {
function handleClick() {
onSelect(user.id);
}
return (
<article
aria-pressed={isSelected}
className={isSelected ? 'user-card user-card--selected' : 'user-card'}
>
<header>
<h3>{user.name}</h3>
<p>{user.email}</p>
</header>
<p>Status: {user.isActive ? 'Active' : 'Inactive'}</p>
<button type="button" onClick={handleClick}>
Select user
</button>
</article>
);
}
export const MemoizedUserCard = memo(UserCard);
Rules of Hooks: call hooks only at the top level of React components or custom hooks, never inside loops, conditions, or nested functions. Effects are for synchronization, not for render-time calculations.
exhaustive-deps.import { useEffect, useState } from 'react';
interface SearchResultsProps {
query: string;
}
type SearchResult = {
id: string;
label: string;
};
export function SearchResults({ query }: SearchResultsProps) {
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const normalizedQuery = query.trim();
useEffect(() => {
if (!normalizedQuery) {
setResults([]);
return;
}
const controller = new AbortController();
async function loadResults() {
setIsLoading(true);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(normalizedQuery)}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error('Failed to load search results.');
}
const data = (await response.json()) as SearchResult[];
setResults(data);
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error(error);
setResults([]);
}
} finally {
setIsLoading(false);
}
}
void loadResults();
return () => {
controller.abort();
};
}, [normalizedQuery]);
if (!normalizedQuery) {
return <p>Type a search term to begin.</p>;
}
if (isLoading) {
return <p>Loading...</p>;
}
return (
<ul>
{results.map((result) => (
<li key={result.id}>{result.label}</li>
))}
</ul>
);
}
import { memo, useCallback, useMemo, useState } from 'react';
type Product = {
id: string;
name: string;
price: number;
};
interface ProductRowProps {
product: Product;
onSelect: (productId: string) => void;
}
const ProductRow = memo(function ProductRow({
product,
onSelect,
}: ProductRowProps) {
return (
<li>
<button type="button" onClick={() => onSelect(product.id)}>
{product.name} - ${product.price}
</button>
</li>
);
});
interface ProductListProps {
products: Product[];
}
export function ProductList({ products }: ProductListProps) {
const [searchTerm, setSearchTerm] = useState('');
const visibleProducts = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return products
.filter((product) =>
product.name.toLowerCase().includes(normalizedSearch)
)
.sort((left, right) => left.price - right.price);
}, [products, searchTerm]);
const handleSelect = useCallback((productId: string) => {
console.log('Selected product:', productId);
}, []);
return (
<section>
<label htmlFor="search-products">Search products</label>
<input
id="search-products"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
<ul>
{visibleProducts.map((product) => (
<ProductRow
key={product.id}
product={product}
onSelect={handleSelect}
/>
))}
</ul>
</section>
);
}
interface GreetingProps {
firstName: string;
lastName: string;
}
export function Greeting({ firstName, lastName }: GreetingProps) {
const fullName = `${firstName} ${lastName}`;
return <p>Hello, {fullName}</p>;
}
useMemo or useCallback. String concatenation, cheap object literals, and unmeasured optimizations usually make code harder to read without improving performance.Standard: organize code by business capability, not by artifact type. Components, tests, styles, routes, slice logic, and feature-specific hooks should live together.
src/
app/
providers/
AppProviders.tsx
router/
router.tsx
store/
store.ts
features/
users/
api/
usersApi.ts
components/
UserCard.tsx
UserCard.module.css
UserCard.test.tsx
hooks/
useUserFilters.ts
routes/
UsersPage.tsx
state/
usersSlice.ts
types.ts
index.ts
dashboard/
components/
DashboardHeader.tsx
DashboardHeader.module.css
routes/
DashboardPage.tsx
index.ts
shared/
components/
Button/
Button.tsx
Button.module.css
Button.test.tsx
hooks/
redux.ts
lib/
formatCurrency.ts
types/
api.ts
main.tsx
export { UsersPage } from './routes/UsersPage';
export { UserCard } from './components/UserCard';
export { usersReducer } from './state/usersSlice';
export type { User } from './types';
Module 2: State Management with Redux Toolkit
Redux state must be implemented with Redux Toolkit only. The approved baseline is configureStore, createSlice, typed hooks, and selectors. Legacy action constants, manual reducers, and untyped Redux access are banned.
createStore, applyMiddleware, manual switch reducers, handwritten action-type strings in components, and connect() HOCs for normal app development.Standard: define the store once in app/store, infer RootState and AppDispatch from the configured store, and attach RTK Query middleware centrally.
import { configureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { authReducer } from '@/features/auth/state/authSlice';
import { usersReducer } from '@/features/users/state/usersSlice';
import { usersApi } from '@/features/users/api/usersApi';
export const store = configureStore({
reducer: {
auth: authReducer,
users: usersReducer,
[usersApi.reducerPath]: usersApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [usersApi.util.resetApiState.type],
},
}).concat(usersApi.middleware),
devTools: import.meta.env.DEV,
});
setupListeners(store.dispatch);
export type AppStore = typeof store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Standard: synchronous state lives in feature-owned slices. Reducers, generated actions, and selectors stay together. Use selectors for derived data instead of duplicating derived values in state.
import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '@/app/store/store';
export type User = {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
};
interface UsersState {
entities: Record<string, User>;
selectedUserId: string | null;
searchTerm: string;
}
const initialState: UsersState = {
entities: {},
selectedUserId: null,
searchTerm: '',
};
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
usersReceived(state, action: PayloadAction<User[]>) {
for (const user of action.payload) {
state.entities[user.id] = user;
}
},
userSelectionChanged(state, action: PayloadAction<string | null>) {
state.selectedUserId = action.payload;
},
searchTermChanged(state, action: PayloadAction<string>) {
state.searchTerm = action.payload;
},
usersCleared(state) {
state.entities = {};
state.selectedUserId = null;
state.searchTerm = '';
},
},
});
export const {
usersReceived,
userSelectionChanged,
searchTermChanged,
usersCleared,
} = usersSlice.actions;
export const usersReducer = usersSlice.reducer;
const selectUsersState = (state: RootState) => state.users;
export const selectUserEntities = createSelector(
[selectUsersState],
(usersState) => usersState.entities
);
export const selectSelectedUserId = createSelector(
[selectUsersState],
(usersState) => usersState.selectedUserId
);
export const selectSearchTerm = createSelector(
[selectUsersState],
(usersState) => usersState.searchTerm
);
export const selectVisibleUsers = createSelector(
[selectUserEntities, selectSearchTerm],
(entities, searchTerm) => {
const normalizedSearchTerm = searchTerm.trim().toLowerCase();
return Object.values(entities)
.filter((user) =>
user.name.toLowerCase().includes(normalizedSearchTerm)
)
.sort((left, right) => left.name.localeCompare(right.name));
}
);
Standard: components never use raw React Redux hooks directly in a strict TypeScript app. Export typed wrappers once and use them everywhere.
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { TypedUseSelectorHook } from 'react-redux';
import type { AppDispatch, AppStore, RootState } from '@/app/store/store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export const useAppStore = useStore.withTypes<AppStore>();
import { searchTermChanged, selectVisibleUsers } from '@/features/users/state/usersSlice';
import { useAppDispatch, useAppSelector } from '@/shared/hooks/redux';
export function UsersToolbar() {
const dispatch = useAppDispatch();
const users = useAppSelector(selectVisibleUsers);
function handleSearchChange(event: React.ChangeEvent<HTMLInputElement>) {
dispatch(searchTermChanged(event.target.value));
}
return (
<section>
<label htmlFor="user-search">Search users</label>
<input id="user-search" onChange={handleSearchChange} />
<p>{users.length} users visible</p>
</section>
);
}
Module 3: Data Fetching and Side Effects
RTK Query is the default solution for data fetching and caching. createAsyncThunk is reserved for workflows that are broader than a single cacheable API resource.
| Scenario | Standard Choice | Reason |
|---|---|---|
| Load users list | RTK Query | Cacheable resource with standard invalidation |
| Create or edit a user | RTK Query mutation | Simple request and response lifecycle |
| Poll dashboard metrics | RTK Query | Polling and refetch lifecycle are built in |
| Login then hydrate app state | Async thunk | Multi-step orchestration and side effects |
| Upload file then redirect and log analytics | Async thunk | Workflow extends beyond API caching |
Standard: define API slices per backend domain, use generated hooks in components, and use tags for cache invalidation. Do not scatter fetch calls across route components.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export type User = {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
};
export interface CreateUserInput {
name: string;
email: string;
role: User['role'];
}
export const usersApi = createApi({
reducerPath: 'usersApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api',
credentials: 'include',
}),
tagTypes: ['User'],
endpoints: (build) => ({
getUsers: build.query<User[], void>({
query: () => '/users',
providesTags: (result) =>
result
? [
...result.map((user) => ({
type: 'User' as const,
id: user.id,
})),
{ type: 'User', id: 'LIST' },
]
: [{ type: 'User', id: 'LIST' }],
}),
getUserById: build.query<User, string>({
query: (userId) => `/users/${userId}`,
providesTags: (_result, _error, userId) => [{ type: 'User', id: userId }],
}),
createUser: build.mutation<User, CreateUserInput>({
query: (body) => ({
url: '/users',
method: 'POST',
body,
}),
invalidatesTags: [{ type: 'User', id: 'LIST' }],
}),
updateUserRole: build.mutation<User, { userId: string; role: User['role'] }>({
query: ({ userId, role }) => ({
url: `/users/${userId}/role`,
method: 'PATCH',
body: { role },
}),
invalidatesTags: (_result, _error, { userId }) => [
{ type: 'User', id: userId },
{ type: 'User', id: 'LIST' },
],
}),
}),
});
export const {
useGetUsersQuery,
useGetUserByIdQuery,
useCreateUserMutation,
useUpdateUserRoleMutation,
} = usersApi;
import { skipToken } from '@reduxjs/toolkit/query';
import {
useCreateUserMutation,
useGetUserByIdQuery,
useGetUsersQuery,
} from '@/features/users/api/usersApi';
export function UsersPage() {
const { data: users = [], isLoading, isError, error, refetch } = useGetUsersQuery();
if (isLoading) {
return <p>Loading users...</p>;
}
if (isError) {
return (
<section>
<p>Failed to load users.</p>
<pre>{JSON.stringify(error, null, 2)}</pre>
<button type="button" onClick={() => refetch()}>
Retry
</button>
</section>
);
}
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} - {user.role}
</li>
))}
</ul>
);
}
interface UserDetailsProps {
userId: string | null;
}
export function UserDetails({ userId }: UserDetailsProps) {
const { data: user, isFetching } = useGetUserByIdQuery(userId ?? skipToken);
if (!userId) {
return <p>Select a user to view details.</p>;
}
if (isFetching) {
return <p>Loading user details...</p>;
}
if (!user) {
return <p>User not found.</p>;
}
return (
<article>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.role}</p>
</article>
);
}
export function CreateUserForm() {
const [createUser, { isLoading, error }] = useCreateUserMutation();
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
await createUser({
name: String(formData.get('name')),
email: String(formData.get('email')),
role: String(formData.get('role')) as 'admin' | 'editor' | 'viewer',
}).unwrap();
event.currentTarget.reset();
}
return (
<form onSubmit={handleSubmit}>
<input name="name" aria-label="Name" />
<input name="email" aria-label="Email" />
<select name="role" aria-label="Role" defaultValue="viewer">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Create user'}
</button>
{error ? <p>Failed to create user.</p> : null}
</form>
);
}
Use thunks when: the work spans multiple services, requires branching on current state, writes to storage, dispatches several slice actions, or otherwise does more than cache a resource.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import type { RootState } from '@/app/store/store';
interface SessionState {
status: 'idle' | 'loading' | 'authenticated' | 'failed';
errorMessage: string | null;
}
const initialState: SessionState = {
status: 'idle',
errorMessage: null,
};
interface LoginInput {
email: string;
password: string;
}
interface LoginResponse {
accessToken: string;
}
export const loginAndBootstrap = createAsyncThunk<
void,
LoginInput,
{ state: RootState; rejectValue: string }
>('session/loginAndBootstrap', async (credentials, thunkApi) => {
try {
const loginResponse = await fetch('/api/session/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
if (!loginResponse.ok) {
return thunkApi.rejectWithValue('Invalid email or password.');
}
const { accessToken } = (await loginResponse.json()) as LoginResponse;
localStorage.setItem('accessToken', accessToken);
const preferencesResponse = await fetch('/api/preferences', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!preferencesResponse.ok) {
return thunkApi.rejectWithValue('Failed to load user preferences.');
}
const preferences = (await preferencesResponse.json()) as {
theme: 'light' | 'dark';
};
thunkApi.dispatch(themeHydrated(preferences.theme));
} catch {
return thunkApi.rejectWithValue('Unexpected login failure.');
}
});
const sessionSlice = createSlice({
name: 'session',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loginAndBootstrap.pending, (state) => {
state.status = 'loading';
state.errorMessage = null;
})
.addCase(loginAndBootstrap.fulfilled, (state) => {
state.status = 'authenticated';
})
.addCase(loginAndBootstrap.rejected, (state, action) => {
state.status = 'failed';
state.errorMessage = action.payload ?? 'Unknown error.';
});
},
});
export const sessionReducer = sessionSlice.reducer;
function themeHydrated(theme: 'light' | 'dark') {
return {
type: 'preferences/themeHydrated',
payload: theme,
} as const;
}
Module 4: The Standard Library Ecosystem
Enterprise React apps need a narrow, explicit ecosystem. The standard stack below is approved because it reinforces type safety, predictable APIs, and maintainable integration boundaries.
| Concern | Approved Library | Notes |
|---|---|---|
| Routing | react-router-dom v6+ data router | Use createBrowserRouter and route modules |
| Forms | react-hook-form + zod | Schema-based validation with inferred types |
| Styling | CSS Modules + clsx | Scoped styles with readable conditional class composition |
| Date handling | date-fns | Small surface area, functional API, tree-shakable |
Standard: route configuration uses createBrowserRouter. Route loaders and route actions are preferred over arbitrary mounting effects for route-owned data and submit behavior.
import {
createBrowserRouter,
Navigate,
Outlet,
RouterProvider,
redirect,
} from 'react-router-dom';
import { DashboardPage } from '@/features/dashboard/routes/DashboardPage';
import { UsersPage } from '@/features/users/routes/UsersPage';
async function requireSessionLoader() {
const response = await fetch('/api/session');
if (response.status === 401) {
throw redirect('/login');
}
return response.json();
}
function AppLayout() {
return (
<div>
<header>Admin Console</header>
<main>
<Outlet />
</main>
</div>
);
}
export const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
loader: requireSessionLoader,
children: [
{
index: true,
element: <Navigate to="/dashboard" replace />,
},
{
path: 'dashboard',
element: <DashboardPage />,
},
{
path: 'users',
element: <UsersPage />,
},
],
},
]);
export function AppRouter() {
return <RouterProvider router={router} />;
}
Standard: forms use react-hook-form with schema validation through zod and @hookform/resolvers/zod. Avoid manual field-state boilerplate for routine forms.
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters.'),
email: z.string().email('Enter a valid email address.'),
role: z.enum(['admin', 'editor', 'viewer']),
});
type CreateUserFormValues = z.infer<typeof createUserSchema>;
export function CreateUserForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<CreateUserFormValues>({
resolver: zodResolver(createUserSchema),
defaultValues: {
name: '',
email: '',
role: 'viewer',
},
mode: 'onBlur',
});
async function onSubmit(values: CreateUserFormValues) {
await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
reset();
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register('name')} />
{errors.name ? <p role="alert">{errors.name.message}</p> : null}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email ? <p role="alert">{errors.email.message}</p> : null}
</div>
<div>
<label htmlFor="role">Role</label>
<select id="role" {...register('role')} >
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
{errors.role ? <p role="alert">{errors.role.message}</p> : null}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Create user'}
</button>
</form>
);
}
Standard: use CSS Modules for component-scoped styles and clsx for conditional composition. This keeps styles local without introducing a runtime design DSL into every component.
/* file: UserCard.module.css */
.card {
display: grid;
gap: 12px;
padding: 16px;
border: 1px solid #d8dee9;
border-radius: 12px;
background: #ffffff;
}
.selected {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
}
.inactive {
opacity: 0.72;
}
.title {
font-size: 1rem;
font-weight: 600;
}
.status {
font-size: 0.875rem;
color: #4b5563;
}
import clsx from 'clsx';
import styles from './UserCard.module.css';
type User = {
id: string;
name: string;
isActive: boolean;
};
interface UserCardProps {
user: User;
isSelected: boolean;
}
export function UserCard({ user, isSelected }: UserCardProps) {
return (
<article
className={clsx(styles.card, {
[styles.selected]: isSelected,
[styles.inactive]: !user.isActive,
})}
>
<h3 className={styles.title}>{user.name}</h3>
<p className={styles.status}>
{user.isActive ? 'Active user' : 'Inactive user'}
</p>
</article>
);
}
Standard: use lightweight date utilities such as date-fns. moment.js is forbidden for new work due to bundle weight, mutability, and outdated ergonomics.
import { format, formatDistanceToNowStrict, isAfter, parseISO } from 'date-fns';
interface AuditTimestampProps {
createdAtIso: string;
expiresAtIso: string;
}
export function AuditTimestamp({ createdAtIso, expiresAtIso }: AuditTimestampProps) {
const createdAt = parseISO(createdAtIso);
const expiresAt = parseISO(expiresAtIso);
const isExpired = isAfter(new Date(), expiresAt);
return (
<section>
<p>Created: {format(createdAt, 'MMM d, yyyy HH:mm')}</p>
<p>Age: {formatDistanceToNowStrict(createdAt)}</p>
<p>Status: {isExpired ? 'Expired' : 'Active'}</p>
</section>
);
}
Module 5: Testing Standards
Tests must verify behavior that users and adjacent systems rely on. The standard stack is React Testing Library with Vitest. Test user interactions, accessibility, Redux integration boundaries, and slice behavior in isolation.
Standard: test rendered behavior and accessible output. Prefer queries by role, label text, and visible content. Avoid asserting on implementation details such as internal state or private methods.
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserCard } from './UserCard';
describe('UserCard', () => {
it('calls onSelect when the user activates the button', async () => {
const user = userEvent.setup();
const handleSelect = vi.fn();
render(
<UserCard
user={{
id: 'user-1',
name: 'Ava Patel',
email: 'ava@example.com',
isActive: true,
}}
onSelect={handleSelect}
/>
);
await user.click(screen.getByRole('button', { name: /select user/i }));
expect(handleSelect).toHaveBeenCalledWith('user-1');
});
it('exposes the visible status text for assistive technology and users', () => {
render(
<UserCard
user={{
id: 'user-2',
name: 'Nina Gomez',
email: 'nina@example.com',
isActive: false,
}}
onSelect={vi.fn()}
/>
);
expect(screen.getByText(/inactive/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /select user/i })).toBeEnabled();
});
});
Standard: use a reusable render helper for connected components and test slices as pure state transitions. Do not spin up the full app shell for every Redux assertion.
import { type PropsWithChildren, type ReactElement } from 'react';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { render } from '@testing-library/react';
import { usersReducer } from '@/features/users/state/usersSlice';
const rootReducer = combineReducers({
users: usersReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
export function createTestStore(preloadedState?: Partial<RootState>) {
return configureStore({
reducer: rootReducer,
preloadedState: preloadedState as RootState,
});
}
export type AppStore = ReturnType<typeof createTestStore>;
interface RenderWithProvidersOptions {
preloadedState?: Partial<RootState>;
store?: AppStore;
}
export function renderWithProviders(
ui: ReactElement,
options: RenderWithProvidersOptions = {}
) {
const store = options.store ?? createTestStore(options.preloadedState);
function Wrapper({ children }: PropsWithChildren) {
return <Provider store={store}>{children}</Provider>;
}
return {
store,
...render(ui, { wrapper: Wrapper }),
};
}
import { describe, expect, it } from 'vitest';
import {
searchTermChanged,
userSelectionChanged,
usersCleared,
usersReceived,
usersReducer,
} from './usersSlice';
describe('usersSlice', () => {
it('stores received users by id', () => {
const state = usersReducer(
undefined,
usersReceived([
{
id: 'user-1',
name: 'Ava Patel',
email: 'ava@example.com',
role: 'admin',
},
])
);
expect(state.entities['user-1']?.name).toBe('Ava Patel');
});
it('updates the current search term and selected user', () => {
const withSearch = usersReducer(undefined, searchTermChanged('ava'));
const withSelection = usersReducer(withSearch, userSelectionChanged('user-1'));
expect(withSelection.searchTerm).toBe('ava');
expect(withSelection.selectedUserId).toBe('user-1');
});
it('clears feature state back to defaults', () => {
const populatedState = usersReducer(
undefined,
usersReceived([
{
id: 'user-1',
name: 'Ava Patel',
email: 'ava@example.com',
role: 'admin',
},
])
);
const clearedState = usersReducer(populatedState, usersCleared());
expect(clearedState.entities).toEqual({});
expect(clearedState.selectedUserId).toBeNull();
expect(clearedState.searchTerm).toBe('');
});
});
Module 6: Code Quality, Linting, and Naming Conventions
Code quality rules exist to reduce ambiguity and make code easier to scan. Naming must be predictable. Linting must be strict enough to catch stale hooks, weak typing, and formatting churn before code review.
Standard: use PascalCase for components and interfaces, camelCase for functions and hooks, and UPPER_SNAKE_CASE for global constants. File names should align with the primary exported symbol or feature purpose.
const API_TIMEOUT_MS = 10_000;
const DEFAULT_PAGE_SIZE = 25;
interface UserTableProps {
currentPage: number;
}
type UserRole = 'admin' | 'editor' | 'viewer';
function mapUserRoleToLabel(role: UserRole) {
switch (role) {
case 'admin':
return 'Administrator';
case 'editor':
return 'Editor';
case 'viewer':
return 'Viewer';
}
}
function usePaginatedUsers(page: number) {
return { page, users: [] as string[] };
}
export function UserTable({ currentPage }: UserTableProps) {
const { users } = usePaginatedUsers(currentPage);
return (
<section>
<h2>Users</h2>
<p>Rows: {users.length}</p>
</section>
);
}
| Symbol | Convention | Example |
|---|---|---|
| Component | PascalCase | UserTable |
| Interface | PascalCase | UserTableProps |
| Function | camelCase | mapUserRoleToLabel |
| Hook | camelCase starting with use | usePaginatedUsers |
| Global constant | UPPER_SNAKE_CASE | API_TIMEOUT_MS |
Standard: baseline linting must include TypeScript support, React Hooks rules, import hygiene, and Prettier compatibility. The goal is to block stale closures, weak typing, unused code, and formatting conflicts before review.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import importPlugin from 'eslint-plugin-import';
import prettierConfig from 'eslint-config-prettier';
export default tseslint.config(
{
ignores: ['dist', 'coverage'],
},
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
import: importPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'import/order': [
'error',
{
alphabetize: { order: 'asc', caseInsensitive: true },
'newlines-between': 'always',
groups: [['builtin', 'external'], ['internal'], ['parent', 'sibling', 'index']],
},
],
},
},
prettierConfig
);
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"useDefineForClassFields": true
}
}
Operating Summary
The handbook standard is intentionally narrow. A smaller approved surface area reduces architecture drift, avoids legacy fallbacks, and makes large React codebases easier to change safely.