GUIDE

React: Modern Frontend Architecture

A deep technical guide to React component architecture, hooks, state management, server-side rendering, testing strategies, and performance optimization. Based on building WEBGYM-V2, the main customer-facing web application.

ReactHooksNext.jsReduxZustandReact QueryJotaiReact Hook FormReact RouterPlaywrightstyled-componentsTesting Library

Table of Contents

  1. 1. Components, Hooks, and Context
  2. 2. State Management Strategies
  3. 3. Server-Side Rendering with Next.js
  4. 4. React 19+ and Concurrent Features
  5. 5. Forms with React Hook Form
  6. 6. Routing with React Router
  7. 7. Testing React Applications
  8. 8. Performance Optimization
  9. 9. Styled-Components and CSS-in-JS

1. Components, Hooks, and Context

Component Design Principles

A well-designed React component follows the single responsibility principle: it renders one piece of UI and manages the state directly related to that UI. Components should be categorized into presentational components (pure rendering, no side effects) and container components (data fetching, business logic). With hooks, this distinction is implemented through custom hooks rather than HOCs or render props.

// Presentational component: pure rendering
interface MemberCardProps {
  member: Member;
  subscription: Subscription | null;
  onRenew: (memberId: string) => void;
}

function MemberCard({ member, subscription, onRenew }: MemberCardProps) {
  const isExpired = subscription?.expiresAt
    ? new Date(subscription.expiresAt) < new Date()
    : true;

  return (
    <Card>
      <Avatar src={member.photoUrl} alt={member.name} />
      <Name>{member.name}</Name>
      <Status expired={isExpired}>
        {isExpired ? 'Expired' : `Active until ${formatDate(subscription!.expiresAt)}`}
      </Status>
      {isExpired && <RenewButton onClick={() => onRenew(member.id)}>Renew</RenewButton>}
    </Card>
  );
}

Custom Hooks: Extracting Reusable Logic

Custom hooks encapsulate stateful logic that can be shared across components. A custom hook is just a function that calls other hooks. The naming convention use* signals to React (and the linter) that the rules of hooks apply. Well-designed custom hooks return stable references and minimize re-renders in consuming components.

// Custom hook: debounced search with abort controller
function useSearch<T>(searchFn: (query: string, signal: AbortSignal) => Promise<T[]>) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<T[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (!query.trim()) { setResults([]); return; }

    const controller = new AbortController();
    const timeoutId = setTimeout(async () => {
      setIsLoading(true);
      setError(null);
      try {
        const data = await searchFn(query, controller.signal);
        setResults(data);
      } catch (e) {
        if (!controller.signal.aborted) setError(e as Error);
      } finally {
        if (!controller.signal.aborted) setIsLoading(false);
      }
    }, 300); // 300ms debounce

    return () => { clearTimeout(timeoutId); controller.abort(); };
  }, [query, searchFn]);

  return { query, setQuery, results, isLoading, error } as const;
}

Context: When and When Not to Use It

React Context solves prop drilling but introduces a performance trap: every component that consumes a context re-renders when the context value changes, regardless of which part of the value it uses. Split contexts by update frequency (a ThemeContext that changes rarely vs. a UserContext that changes on auth events). For high-frequency state like form inputs or animations, context is the wrong tool. Use Zustand, Jotai, or direct prop passing instead.

// Split context pattern: separate value from dispatch
const AuthStateContext = createContext<AuthState | null>(null);
const AuthDispatchContext = createContext<AuthDispatch | null>(null);

function AuthProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, initialAuthState);

  // Memoize to prevent unnecessary re-renders
  const stateValue = useMemo(() => state, [state]);

  return (
    <AuthStateContext.Provider value={stateValue}>
      <AuthDispatchContext.Provider value={dispatch}>
        {children}
      </AuthDispatchContext.Provider>
    </AuthStateContext.Provider>
  );
}

// Components that only dispatch actions don't re-render on state changes
function LogoutButton() {
  const dispatch = useContext(AuthDispatchContext)!;
  return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Logout</button>;
}

2. State Management Strategies

Choosing the Right Tool

State management in React is not a one-size-fits-all problem. The decision depends on the type of state: local UI state (useState), cross-component UI state (Zustand/Jotai), server cache state (React Query/SWR), URL state (React Router), and form state (React Hook Form). Mixing these concerns into a single store (as early Redux patterns encouraged) creates unnecessary complexity and re-renders.

Redux Toolkit

Redux Toolkit (RTK) is the official, opinionated Redux setup. It eliminates boilerplate through createSlice (reducers + actions), createAsyncThunk (async operations), and createEntityAdapter (normalized state). RTK Query adds built-in data fetching and caching. Redux remains relevant for applications with complex client-side state that multiple components read and write: shopping carts, multi-step forms, real-time collaboration state.

Zustand

Zustand provides a minimal store without the boilerplate of Redux. It uses a simple hook-based API and supports middleware (persistence, devtools, immer). The key advantage is selector-based subscriptions: components only re-render when the specific slice of state they select changes, unlike Context which triggers re-renders on any change.

// Zustand store with typed selectors and middleware
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

interface GymStore {
  members: Member[];
  selectedMemberId: string | null;
  filters: { status: 'all' | 'active' | 'expired'; search: string };
  setFilter: (key: keyof GymStore['filters'], value: string) => void;
  selectMember: (id: string | null) => void;
  addMember: (member: Member) => void;
}

const useGymStore = create<GymStore>()(
  devtools(
    persist(
      immer((set) => ({
        members: [],
        selectedMemberId: null,
        filters: { status: 'all', search: '' },
        setFilter: (key, value) => set((state) => { state.filters[key] = value; }),
        selectMember: (id) => set((state) => { state.selectedMemberId = id; }),
        addMember: (member) => set((state) => { state.members.push(member); }),
      })),
      { name: 'gym-store' }
    )
  )
);

// Component: only re-renders when filters change, not when members change
function FilterBar() {
  const filters = useGymStore((s) => s.filters);
  const setFilter = useGymStore((s) => s.setFilter);
  // ...
}

React Query (TanStack Query)

React Query treats server state as a separate concern from client state. It handles caching, background refetching, stale-while-revalidate, pagination, infinite scrolling, and optimistic updates. The mental model: the server is the source of truth, and React Query synchronizes a local cache with it. This eliminates the need to store fetched data in Redux or Context.

// React Query: typed hooks for API data
const memberKeys = {
  all: ['members'] as const,
  lists: () => [...memberKeys.all, 'list'] as const,
  list: (filters: MemberFilters) => [...memberKeys.lists(), filters] as const,
  details: () => [...memberKeys.all, 'detail'] as const,
  detail: (id: string) => [...memberKeys.details(), id] as const,
};

function useMembers(filters: MemberFilters) {
  return useQuery({
    queryKey: memberKeys.list(filters),
    queryFn: () => api.members.list(filters),
    staleTime: 5 * 60 * 1000,     // 5 minutes
    placeholderData: keepPreviousData, // keep old data while fetching new page
  });
}

function useUpdateMember() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data: UpdateMemberDto) => api.members.update(data),
    onSuccess: (updated) => {
      // Invalidate list queries and update the detail cache
      queryClient.invalidateQueries({ queryKey: memberKeys.lists() });
      queryClient.setQueryData(memberKeys.detail(updated.id), updated);
    },
  });
}

Jotai: Atomic State Management

Jotai takes a bottom-up approach to state management using atoms as the primitive unit. Each atom holds a single piece of state, and derived atoms compute values from other atoms. Components subscribe only to the atoms they read, ensuring minimal re-renders. Jotai excels for fine-grained state that Context handles poorly: toggles, selections, filters spread across many components.

// Jotai: atomic state with derived atoms
import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Primitive atoms
const membersAtom = atom<Member[]>([]);
const searchQueryAtom = atom('');
const statusFilterAtom = atomWithStorage<'all' | 'active' | 'expired'>('statusFilter', 'all');

// Derived atom: automatically recomputes when dependencies change
const filteredMembersAtom = atom((get) => {
  const members = get(membersAtom);
  const query = get(searchQueryAtom).toLowerCase();
  const status = get(statusFilterAtom);

  return members.filter((m) => {
    const matchesQuery = m.name.toLowerCase().includes(query) || m.email.toLowerCase().includes(query);
    const matchesStatus = status === 'all' || m.status === status;
    return matchesQuery && matchesStatus;
  });
});

// Async atom: fetches data and populates the members atom
const fetchMembersAtom = atom(null, async (get, set) => {
  const response = await fetch('/api/members');
  const data = await response.json();
  set(membersAtom, data);
});

// Component: only re-renders when filtered results change
function MemberSearch() {
  const [query, setQuery] = useAtom(searchQueryAtom);
  const filtered = useAtomValue(filteredMembersAtom);
  // ...
}

3. Server-Side Rendering with Next.js

Next.js provides three rendering strategies: Static Site Generation (SSG) at build time, Server-Side Rendering (SSR) on every request, and Incremental Static Regeneration (ISR) which combines both. The App Router (Next.js 13+) introduces React Server Components (RSC) which render on the server and send only the HTML to the client, eliminating JavaScript bundles for non-interactive content.

React Server Components

Server Components run exclusively on the server. They can directly access databases, file systems, and environment variables without exposing them to the client. They produce zero client-side JavaScript. Client Components (marked with "use client") handle interactivity. The boundary between server and client components is the architectural decision that determines bundle size and performance.

// app/members/page.tsx - Server Component (default in App Router)
import { db } from '@/lib/database';
import { MemberList } from './member-list'; // Client Component

// This runs on the server only - db access is safe here
export default async function MembersPage({
  searchParams,
}: {
  searchParams: { page?: string; status?: string };
}) {
  const page = parseInt(searchParams.page ?? '1');
  const status = searchParams.status ?? 'all';

  // Direct database query - no API route needed
  const { members, total } = await db.members.findMany({
    where: status !== 'all' ? { status } : undefined,
    skip: (page - 1) * 20,
    take: 20,
  });

  // MemberList is a Client Component that handles interaction
  return (
    <main>
      <h1>Members ({total})</h1>
      <MemberList members={members} total={total} currentPage={page} />
    </main>
  );
}

Data Fetching Patterns

In the App Router, data fetching happens in Server Components using async/await. Next.js extends the native fetch to add caching and revalidation. Use cache: 'force-cache' for static data (equivalent to SSG), cache: 'no-store' for dynamic data (equivalent to SSR), and next: { revalidate: 60 } for ISR with a 60-second revalidation window.

4. React 19+ and Concurrent Features

Suspense for Data Fetching

React 18 extends Suspense beyond lazy-loaded components to support data fetching. A Suspense boundary wraps components that may suspend while loading data, displaying a fallback UI until the data resolves. Nested Suspense boundaries allow fine-grained loading states: a page shell can appear instantly while individual sections load independently.

// Nested Suspense boundaries for granular loading states
function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />  {/* Suspends while fetching stats */}
      </Suspense>
      <div className="grid">
        <Suspense fallback={<MemberListSkeleton />}>
          <RecentMembers />  {/* Suspends independently */}
        </Suspense>
        <Suspense fallback={<ActivitySkeleton />}>
          <ActivityFeed />  {/* Suspends independently */}
        </Suspense>
      </div>
    </main>
  );
}

useTransition and useDeferredValue

useTransition marks state updates as non-urgent, allowing React to keep the UI responsive during expensive re-renders. The isPending flag lets you show a subtle loading indicator without replacing visible content. useDeferredValue achieves a similar effect for values: it returns a deferred copy that lags behind the actual value, letting React prioritize urgent updates like typing.

// useTransition: keep search input responsive while filtering large lists
function MemberDirectory({ members }: { members: Member[] }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
    // Urgent: update the input immediately
    setQuery(e.target.value);

    // Non-urgent: filter the list in the background
    startTransition(() => {
      setFilteredMembers(
        members.filter((m) => m.name.toLowerCase().includes(e.target.value.toLowerCase()))
      );
    });
  };

  return (
    <>
      <input value={query} onChange={handleSearch} placeholder="Search members..." />
      {isPending && <Spinner size="sm" />}
      <MemberList members={filteredMembers} />
    </>
  );
}

// useDeferredValue: defer expensive rendering
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const results = useMemo(
    () => expensiveSearch(deferredQuery),
    [deferredQuery]
  );

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      {results.map((r) => <ResultCard key={r.id} result={r} />)}
    </div>
  );
}

Concurrent Rendering Patterns

Concurrent rendering allows React to prepare multiple versions of the UI simultaneously and switch between them without blocking. The key patterns are: (1) wrapping navigations in startTransition to avoid showing a blank page during route changes, (2) using Suspense with streaming SSR in Next.js to send HTML progressively, and (3) combining useDeferredValue with memoized components to keep expensive lists responsive during user input.

In production, WEBGYM-V2 adopted React 18 concurrent features for the class schedule view. The schedule rendered 500+ time slots across 7 days. Using useTransition for date range changes and useDeferredValue for instructor search kept the UI responsive while the heavy calendar grid re-rendered in the background.

React 19: New Hooks and Patterns (Stable Dec 2024)

React 19 introduces new hooks that simplify common patterns: useActionState manages form submission state (pending, error, result) in a single hook. useOptimistic provides optimistic UI updates that revert on failure. useFormStatus reads the parent form's pending state from any child component. The use() API reads promises and context directly in render, replacing many useEffect-based data loading patterns. Concurrent rendering is now the default for all updates.

// React 19: useActionState for form submissions
import { useActionState } from 'react';

function RenewMembership({ memberId }: { memberId: string }) {
  const [state, submitAction, isPending] = useActionState(
    async (prevState, formData: FormData) => {
      const plan = formData.get('plan') as string;
      const result = await renewMembership(memberId, plan);
      return { success: true, message: `Renewed: ${result.plan}` };
    },
    { success: false, message: '' }
  );

  return (
    <form action={submitAction}>
      <select name="plan">
        <option value="monthly">Monthly</option>
        <option value="annual">Annual</option>
      </select>
      <button disabled={isPending}>
        {isPending ? 'Renewing...' : 'Renew'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  );
}

// React 19: useOptimistic for instant UI feedback
import { useOptimistic } from 'react';

function MemberList({ members }: { members: Member[] }) {
  const [optimisticMembers, addOptimistic] = useOptimistic(
    members,
    (current, updatedMember: Member) =>
      current.map(m => m.id === updatedMember.id ? updatedMember : m)
  );

  async function toggleStatus(member: Member) {
    const updated = { ...member, status: member.status === 'active' ? 'inactive' : 'active' };
    addOptimistic(updated); // Instant UI update
    await updateMemberStatus(member.id, updated.status); // Server call
  }

  return optimisticMembers.map(m => (
    <MemberCard key={m.id} member={m} onToggle={() => toggleStatus(m)} />
  ));
}

// React 19: ref as prop (no more forwardRef)
function Input({ ref, ...props }: { ref?: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />;
}

Server Actions: Replacing API Routes

Server Actions (stable in React 19 / Next.js 14+) allow calling server-side functions directly from client components using the "use server" directive. They replace REST/GraphQL endpoints for mutations, reducing form submission code by 50-70%. Server Actions integrate with useActionState and useFormStatus for built-in loading and error states, and support progressive enhancement (forms work without JavaScript).

// Server Action: runs on server, called from client
'use server';

export async function createMember(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  // Direct database access - no API route needed
  const member = await db.member.create({ data: { name, email } });
  revalidatePath('/members');
  return { success: true, id: member.id };
}

// Client component using the Server Action
'use client';
import { useFormStatus } from 'react-dom';
import { createMember } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Creating...' : 'Create Member'}</button>;
}

function NewMemberForm() {
  return (
    <form action={createMember}>
      <input name="name" required />
      <input name="email" type="email" required />
      <SubmitButton />
    </form>
  );
}

React Compiler v1.0 (Oct 2025)

The React Compiler (v1.0, October 2025) performs automatic memoization at build time, eliminating the need for manual useMemo, useCallback, and React.memo. The compiler analyzes component render functions and automatically determines which values and components can be memoized. Benchmarks show measurably faster initial page loads and significantly quicker interactions on complex UIs. It is a Babel plugin that requires zero code changes to adopt.

// Before React Compiler: manual memoization everywhere
function MemberDashboard({ members, onSelect }) {
  const activeMembers = useMemo(
    () => members.filter(m => m.status === 'active'),
    [members]
  );
  const handleSelect = useCallback(
    (id) => onSelect(id),
    [onSelect]
  );
  return <MemberList members={activeMembers} onSelect={handleSelect} />;
}
const MemberList = React.memo(({ members, onSelect }) => { /* ... */ });

// After React Compiler: write natural code, compiler handles memoization
function MemberDashboard({ members, onSelect }) {
  const activeMembers = members.filter(m => m.status === 'active');
  return <MemberList members={activeMembers} onSelect={(id) => onSelect(id)} />;
}
function MemberList({ members, onSelect }) { /* ... */ }
// Compiler auto-memoizes both components, the filter, and the callback
React 19 + the React Compiler represent the largest shift in React development patterns since hooks. Server Actions collapse the client-server boundary for mutations, useActionState/useOptimistic replace most loading/error state management, and the compiler eliminates memoization as a manual concern. New projects should adopt these patterns from the start; existing projects can migrate incrementally.

5. Forms with React Hook Form

React Hook Form minimizes re-renders by using uncontrolled inputs with ref-based registration. Unlike controlled form libraries that re-render on every keystroke, React Hook Form only triggers re-renders on form submission or validation changes. For complex forms with many fields, this difference is measurable: a 30-field membership form re-renders 30 times per keystroke with controlled inputs vs. zero times with React Hook Form.

Registration and Validation

// React Hook Form with Zod schema validation
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const memberSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  phone: z.string().regex(/^\+?[1-9]\d{6,14}$/, 'Invalid phone number'),
  plan: z.enum(['basic', 'premium', 'vip']),
  startDate: z.string().refine((d) => new Date(d) >= new Date(), {
    message: 'Start date must be in the future',
  }),
});

type MemberFormData = z.infer<typeof memberSchema>;

function MemberRegistrationForm({ onSubmit }: { onSubmit: (data: MemberFormData) => void }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm<MemberFormData>({
    resolver: zodResolver(memberSchema),
    defaultValues: { plan: 'basic' },
  });

  const submit = async (data: MemberFormData) => {
    await onSubmit(data);
    reset();
  };

  return (
    <form onSubmit={handleSubmit(submit)}>
      <label>
        Name
        <input {...register('name')} />
        {errors.name && <span role="alert">{errors.name.message}</span>}
      </label>
      <label>
        Email
        <input type="email" {...register('email')} />
        {errors.email && <span role="alert">{errors.email.message}</span>}
      </label>
      <label>
        Plan
        <select {...register('plan')}>
          <option value="basic">Basic</option>
          <option value="premium">Premium</option>
          <option value="vip">VIP</option>
        </select>
      </label>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Registering...' : 'Register Member'}
      </button>
    </form>
  );
}

Advanced Patterns: Dynamic Fields and Watch

For multi-step forms or forms with dynamic field arrays, React Hook Form provides useFieldArray for adding/removing repeatable sections and watch for reactive field dependencies. The useFormContext hook shares form state across deeply nested components without prop drilling, combining well with compound component patterns.

In production, WEBGYM-V2's membership registration form had 28 fields across 4 steps. Migrating from Formik (controlled) to React Hook Form (uncontrolled) reduced re-renders per keystroke from 28 to 0 and improved perceived input responsiveness on low-end Android devices used at gym reception desks.

6. Routing with React Router

React Router v6 uses a nested route structure that mirrors the component tree. Routes are defined declaratively with <Route> elements or the data router API (createBrowserRouter). The data router supports loaders (fetch data before rendering), actions (handle form submissions), and error boundaries at the route level, enabling a more structured data flow.

Data Router and Loaders

// React Router v6 data router with loaders and error boundaries
import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <GlobalError />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'members',
        element: <MembersLayout />,
        children: [
          {
            index: true,
            element: <MemberList />,
            loader: async ({ request }) => {
              const url = new URL(request.url);
              const status = url.searchParams.get('status') ?? 'all';
              return api.members.list({ status });
            },
          },
          {
            path: ':memberId',
            element: <MemberDetail />,
            loader: async ({ params }) => api.members.get(params.memberId!),
            errorElement: <MemberNotFound />,
          },
        ],
      },
      {
        path: 'admin',
        element: <AdminLayout />,
        loader: requireAuth,  // redirect if not authenticated
        children: [
          { path: 'reports', element: <Reports />, lazy: () => import('./pages/Reports') },
        ],
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

Protected Routes and Guards

Route protection can be implemented through loaders that check authentication status and redirect, or through wrapper components that conditionally render routes. The loader approach is preferred because it runs before the component renders, preventing flash of unauthorized content. Combine with React Router's defer utility to stream authenticated layouts while loading user-specific data.

7. Testing React Applications

React Testing Library: Testing Behavior, Not Implementation

React Testing Library (RTL) enforces testing from the user's perspective. Instead of testing internal state or method calls, RTL queries elements by accessible role, text, label, and placeholder. The guiding principle: "The more your tests resemble the way your software is used, the more confidence they give you." Avoid testing implementation details like state values or component instance methods.

// Testing a subscription renewal flow
describe('MemberCard', () => {
  it('shows renewal button when subscription is expired', () => {
    const onRenew = jest.fn();
    render(
      <MemberCard
        member={mockMember}
        subscription={{ ...mockSubscription, expiresAt: '2024-01-01' }}
        onRenew={onRenew}
      />
    );

    expect(screen.getByText('Expired')).toBeInTheDocument();
    const renewBtn = screen.getByRole('button', { name: /renew/i });
    expect(renewBtn).toBeInTheDocument();

    fireEvent.click(renewBtn);
    expect(onRenew).toHaveBeenCalledWith(mockMember.id);
  });

  it('shows active status when subscription is valid', () => {
    render(
      <MemberCard
        member={mockMember}
        subscription={{ ...mockSubscription, expiresAt: '2030-12-31' }}
        onRenew={jest.fn()}
      />
    );

    expect(screen.getByText(/active until/i)).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /renew/i })).not.toBeInTheDocument();
  });
});

Testing Custom Hooks

Use renderHook from RTL to test custom hooks in isolation. For hooks that depend on providers (React Query, Redux, Context), wrap them in a custom wrapper. For hooks with async effects, use waitFor to assert on the final state after all updates have settled.

// Testing a custom hook with async behavior
describe('useSearch', () => {
  it('debounces search queries and returns results', async () => {
    const searchFn = jest.fn().mockResolvedValue([{ id: '1', name: 'Result' }]);
    const { result } = renderHook(() => useSearch(searchFn));

    act(() => { result.current.setQuery('test'); });

    // Should not call immediately (debounced)
    expect(searchFn).not.toHaveBeenCalled();

    // Wait for debounce timeout + async resolution
    await waitFor(() => {
      expect(searchFn).toHaveBeenCalledWith('test', expect.any(AbortSignal));
      expect(result.current.results).toHaveLength(1);
      expect(result.current.isLoading).toBe(false);
    });
  });

  it('aborts previous request when query changes', async () => {
    const searchFn = jest.fn().mockImplementation(
      (query, signal) => new Promise((resolve, reject) => {
        signal.addEventListener('abort', () => reject(new DOMException('Aborted')));
        setTimeout(() => resolve([{ id: query }]), 500);
      })
    );

    const { result } = renderHook(() => useSearch(searchFn));
    act(() => { result.current.setQuery('first'); });
    act(() => { result.current.setQuery('second'); });

    await waitFor(() => {
      expect(result.current.results).toEqual([{ id: 'second' }]);
    });
  });
});

Integration Testing with MSW

Mock Service Worker (MSW) intercepts HTTP requests at the network level, enabling realistic integration tests without mocking fetch or axios. Define handlers that mirror your API, and tests exercise the full component tree including data fetching, loading states, error handling, and user interactions.

End-to-End Testing with Playwright

Playwright tests the application as a real user would, running a full browser (Chromium, Firefox, or WebKit). Unlike Cypress, Playwright runs outside the browser, enabling multi-tab testing, file downloads, and native mobile emulation. For React applications, Playwright complements RTL: RTL tests component behavior in isolation, while Playwright validates complete user flows across pages.

// Playwright: E2E test for member registration flow
import { test, expect } from '@playwright/test';

test.describe('Member Registration', () => {
  test('registers a new member and shows confirmation', async ({ page }) => {
    await page.goto('/members/register');

    // Fill the form
    await page.getByLabel('Name').fill('Maria Garcia');
    await page.getByLabel('Email').fill('maria@example.com');
    await page.getByLabel('Phone').fill('+573001234567');
    await page.getByLabel('Plan').selectOption('premium');

    // Submit and verify
    await page.getByRole('button', { name: 'Register Member' }).click();
    await expect(page.getByText('Registration successful')).toBeVisible();
    await expect(page).toHaveURL(/\/members\/[a-z0-9-]+/);
  });

  test('shows validation errors for invalid inputs', async ({ page }) => {
    await page.goto('/members/register');
    await page.getByRole('button', { name: 'Register Member' }).click();

    await expect(page.getByText('Name must be at least 2 characters')).toBeVisible();
    await expect(page.getByText('Invalid email address')).toBeVisible();
  });
});

8. Performance Optimization

Preventing Unnecessary Re-Renders

Every render in React creates a new tree of elements that gets reconciled with the previous tree. While React's diffing algorithm is fast, unnecessary renders accumulate and cause jank. The primary tools are: React.memo (skip re-render if props are shallowly equal), useMemo (cache computed values), and useCallback (cache function references). Use them strategically, not everywhere.

// Optimized list rendering with memoization
const MemberRow = memo(function MemberRow({ member, onSelect }: MemberRowProps) {
  // Only re-renders when member object or onSelect reference changes
  return (
    <tr onClick={() => onSelect(member.id)}>
      <td>{member.name}</td>
      <td>{member.email}</td>
      <td>{formatDate(member.joinedAt)}</td>
    </tr>
  );
});

function MemberTable({ members }: { members: Member[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);

  // Stable reference: does not change between renders
  const handleSelect = useCallback((id: string) => {
    setSelectedId(id);
  }, []);

  // Expensive computation: only recalculates when members array changes
  const sortedMembers = useMemo(
    () => [...members].sort((a, b) => a.name.localeCompare(b.name)),
    [members]
  );

  return (
    <table>
      <tbody>
        {sortedMembers.map((m) => (
          <MemberRow key={m.id} member={m} onSelect={handleSelect} />
        ))}
      </tbody>
    </table>
  );
}

Code Splitting and Lazy Loading

Code splitting reduces the initial bundle size by loading modules on demand. Use React.lazy with Suspense for route-based splitting. For below-the-fold components, use IntersectionObserver to trigger loading when the component enters the viewport. Next.js handles route-based splitting automatically; additional splitting should target large component trees or heavy libraries.

// Route-based code splitting
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const MemberProfile = lazy(() => import('./pages/MemberProfile'));
const Reports = lazy(() => import('./pages/Reports'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/members/:id" element={<MemberProfile />} />
        <Route path="/reports" element={<Reports />} />
      </Routes>
    </Suspense>
  );
}

Virtualization for Large Lists

For lists with hundreds or thousands of items, rendering all DOM nodes causes severe performance degradation. Libraries like @tanstack/react-virtual render only the visible items plus a small overscan buffer. The DOM contains 20-50 elements instead of 10,000, reducing memory usage and improving scroll performance dramatically.

In production, WEBGYM-V2's member list page rendered up to 5,000 members in a single view. Implementing virtualization with react-virtual reduced DOM nodes from 5,000+ rows to ~30 visible rows, dropping initial render time from 2.4s to 80ms and eliminating scroll jank entirely.

9. Styled-Components and CSS-in-JS

CSS-in-JS libraries like styled-components colocate styles with components, enabling dynamic styling based on props and theme values. Styles are scoped automatically (no class name collisions), and unused styles are tree-shaken during build. The trade-off is runtime overhead: styled-components generates CSS at runtime, which adds to the JavaScript bundle and execution cost.

Styled-Components Patterns

// Theme-aware styled components with TypeScript
import styled, { css } from 'styled-components';

interface Theme {
  colors: { primary: string; bg: string; text: string; muted: string };
  spacing: (n: number) => string;
  borderRadius: string;
}

const Card = styled.div<{ elevated?: boolean }>`
  background: ${({ theme }) => theme.colors.bg};
  border-radius: ${({ theme }) => theme.borderRadius};
  padding: ${({ theme }) => theme.spacing(3)};

  ${({ elevated }) => elevated && css`
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
    border: 1px solid ${({ theme }) => theme.colors.primary}22;
  `}
`;

const StatusBadge = styled.span<{ status: 'active' | 'expired' | 'pending' }>`
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 600;

  ${({ status, theme }) => {
    const colors = {
      active: { bg: '#dcfce7', text: '#166534' },
      expired: { bg: '#fee2e2', text: '#991b1b' },
      pending: { bg: '#fef3c7', text: '#92400e' },
    };
    return css`
      background: ${colors[status].bg};
      color: ${colors[status].text};
    `;
  }}
`;

Performance Considerations

For applications where runtime CSS generation is a bottleneck, consider zero-runtime alternatives: vanilla-extract (TypeScript-first, extracted at build time), Tailwind CSS (utility classes, purged in production), or CSS Modules (scoped class names, no runtime). For WEBGYM-V2, styled-components performed well because the component count was manageable and the theme switching requirement justified runtime style generation.

In production, styled-components was the right choice for WEBGYM-V2 because the application supported white-label theming: each gym customized colors, fonts, and spacing through a theme object. Runtime CSS generation allowed theme changes without rebuilding the application. For the admin dashboard (Angular), we used SCSS instead since theming was not a requirement.

Latest Updates (April 2026)

React 19.1: Owner Stacks and Suspense Improvements

React 19.1 (March 28, 2025) introduced Owner Stacks, a dev-only debugging feature that traces which components are responsible for rendering a particular component via captureOwnerStack(). Unlike Component Stacks that show the DOM hierarchy, Owner Stacks show the rendering chain -- invaluable for debugging complex trees. React 19.1 also expanded Suspense support across client, server, and hydration phases, and improved streaming error handling in React Server Components.

React 19.2.x: Activity API and useEffectEvent

React 19.2 (stable October 2025, latest patch 19.2.5 as of April 2026) introduced the Activity API for offscreen rendering and the useEffectEvent hook, which captures the latest values of props and state without adding them to the effect dependency array. The React Compiler (v1.0, Babel plugin) is now production-ready and widely adopted, automatically memoizing components and eliminating the need for manual useMemo, useCallback, and React.memo. Benchmarks show measurably faster initial page loads and interactions on complex UIs with zero code changes required.

Server Components Patterns in Production

React Server Components are now stable across the 19.x line and will not break between minor versions. The cacheSignal utility enables cleanup of asynchronous operations when a component's cache() scope expires. Partial Pre-rendering (PPR) allows pre-rendering static shell content from a CDN and resuming with dynamic content on the client, combining the best of SSG and SSR. Server Actions continue to replace traditional API routes for mutations, reducing form submission code by 50-70% while supporting progressive enhancement.

With React 19.2.x stable and the React Compiler production-ready, the React ecosystem has settled into a new baseline: Server Components for data fetching, Server Actions for mutations, the Compiler for automatic memoization, and the Activity API for offscreen preparation. New projects should adopt these patterns from the start. Existing projects can upgrade incrementally since React 19.2 is a drop-in replacement for 19.0/19.1.

More Guides