Back to handbooks index

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.

React 18+ Redux Toolkit First TypeScript Strict Legacy Patterns Forbidden

Table of Contents

RULE
Non-negotiable baseline: use functional components, Hooks, strict TypeScript, Redux Toolkit, and RTK Query. Class components, 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.

Component Patterns Functional Components Only

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.

Typed props Composition first No React.FC by default
Do
  • Define narrow prop contracts.
  • Export named components.
  • Keep rendering responsibility focused.
  • Prefer composition over inheritance.
Do Not
  • Do not use class components.
  • Do not use any for 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);
Hooks Best Practices Top-level Only

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.

Honest dependencies No lint suppression by habit Optimize after profiling
NOTE
Dependency arrays must be truthful. If a value is read inside an effect, it belongs in the dependency array unless it is intentionally stable by design. Restructure code before you suppress 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>;
}
AVOID
Do not wrap trivial work in useMemo or useCallback. String concatenation, cheap object literals, and unmeasured optimizations usually make code harder to read without improving performance.
File Structure and Colocation Feature First

Standard: organize code by business capability, not by artifact type. Components, tests, styles, routes, slice logic, and feature-specific hooks should live together.

Feature folders Colocated tests Minimal public APIs
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.

BAN
Forbidden by default: createStore, applyMiddleware, manual switch reducers, handwritten action-type strings in components, and connect() HOCs for normal app development.
Store Configuration configureStore

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;
Slices createSlice

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));
  }
);
Typed Custom Hooks useAppDispatch / useAppSelector

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.

ScenarioStandard ChoiceReason
Load users listRTK QueryCacheable resource with standard invalidation
Create or edit a userRTK Query mutationSimple request and response lifecycle
Poll dashboard metricsRTK QueryPolling and refetch lifecycle are built in
Login then hydrate app stateAsync thunkMulti-step orchestration and side effects
Upload file then redirect and log analyticsAsync thunkWorkflow extends beyond API caching
RTK Query createApi

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>
  );
}
Async Thunks Fallback for Workflows

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.

ConcernApproved LibraryNotes
Routingreact-router-dom v6+ data routerUse createBrowserRouter and route modules
Formsreact-hook-form + zodSchema-based validation with inferred types
StylingCSS Modules + clsxScoped styles with readable conditional class composition
Date handlingdate-fnsSmall surface area, functional API, tree-shakable
Routing Data Router Architecture

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} />;
}
AVOID
Do not build routing around ad hoc wrapper components and imperative redirects. The data router already provides route loaders, actions, redirects, error boundaries, and nested layouts.
Forms and Validation react-hook-form + zod

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>
  );
}
Styling Strategy CSS Modules + clsx

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>
  );
}
BAN
Do not mix multiple styling systems without a platform-level reason. Pick a primary strategy and keep shared tokens, spacing, and semantics consistent across the app.
Date Handling date-fns

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.

Component Testing Vitest + React Testing Library

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();
  });
});
TEST
Prefer behavior over implementation details. If a test must know about component internals to pass, the test is probably attached to the wrong seam.
Redux Testing renderWithProviders + slice tests

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.

Naming Conventions Consistent by Symbol Type

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>
  );
}
SymbolConventionExample
ComponentPascalCaseUserTable
InterfacePascalCaseUserTableProps
FunctioncamelCasemapUserRoleToLabel
HookcamelCase starting with useusePaginatedUsers
Global constantUPPER_SNAKE_CASEAPI_TIMEOUT_MS
Linting Setup ESLint + Prettier

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
  }
}
TIP
Lint rules should shape architecture, not just formatting. The React Hooks plugin, strict TypeScript rules, and import ordering catch the kinds of mistakes that otherwise survive into runtime and code review.

Operating Summary

Functional Components
->
Redux Toolkit
->
RTK Query
->
Typed Ecosystem
->
Behavior Tests
->
Strict Linting

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.