React patterns & Hooks

Guide Agents to use custom Hook extraction, state lifting, and data-fetch patterns while honoring eslint-plugin-react-hooks and project folder conventions.

The SKILL should separate server vs client components (if using App Router), legitimate useEffect uses vs alternatives (events, key, derived state), and when useMemo/useCallback are warranted instead of default wrapping.

For data fetching, state rules for TanStack Query, SWR, or RSC fetch; loading and error behavior under Suspense boundaries must be predictable.

Context needs documented granularity and split strategies to avoid huge global objects and useless re-renders; list key and a11y attributes in the same pattern notes.

  • Strict Mode and concurrent features: if enabled, document expected double effect invocation and testing style.
  • Forms: controlled vs uncontrolled and integration with zod or similar validators.
  • Testing: @testing-library query priority and assertions that avoid implementation details.

Custom Hooks and Context+Reducer State Management

// Custom Hook design: useAsync — encapsulate async request state
function useAsync<T>(asyncFn: () => Promise<T>, deps: React.DependencyList) {
  const [state, dispatch] = React.useReducer(asyncReducer<T>, {
    status: 'idle', data: null, error: null
  });

  React.useEffect(() => {
    let cancelled = false;  // prevent race conditions
    dispatch({ type: 'LOADING' });
    asyncFn().then(
      data => { if (!cancelled) dispatch({ type: 'SUCCESS', data }); },
      error => { if (!cancelled) dispatch({ type: 'ERROR', error }); }
    );
    return () => { cancelled = true; };  // cleanup: cancel on unmount or dep change
  }, deps);   // eslint: exhaustive-deps

  return state;
}

// useDebounce: debounce a value to avoid rapid API calls
function useDebounce<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = React.useState(value);
  React.useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);  // clear previous timer
  }, [value, delayMs]);
  return debounced;
}

// Context + Reducer: lightweight global state without Redux
type CartAction =
  | { type: 'ADD_ITEM'; item: CartItem }
  | { type: 'REMOVE_ITEM'; id: string }
  | { type: 'CLEAR' };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return { ...state, items: [...state.items, action.item] };
    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'CLEAR':
      return { items: [] };
  }
}

// Split state and dispatch into separate Contexts to avoid unnecessary re-renders
const CartStateCtx = React.createContext<CartState | null>(null);
const CartDispatchCtx = React.createContext<React.Dispatch<CartAction> | null>(null);

export function CartProvider({ children }) {
  const [state, dispatch] = React.useReducer(cartReducer, { items: [] });
  return (
    <CartStateCtx.Provider value={state}>
      <CartDispatchCtx.Provider value={dispatch}>
        {children}
      </CartDispatchCtx.Provider>
    </CartStateCtx.Provider>
  );
}
  • Single responsibility: one primary exported component per file by default; split complex UI into children or colocated private modules—avoid thousand-line JSX files.
  • Compound components: parent shares implicit state via Context or explicit child APIs (e.g. Tabs.Tab) with a stable external contract.
  • Controlled wrappers: unify value/onChange shape at library boundaries; avoid half-controlled mixes that tear state.
Anti-pattern: stuffing lots of state into one Context to dodge re-renders—split Context, use selective subscriptions, or keep volatile state local.

Composition and slots

Prefer children, render props, or explicit slot props over long conditional trees of child type strings in the parent. The SKILL can require “extension points” to be exposed only through composition for typing and test doubles.

              ┌──────────────────┐
              │     Parent       │
              │  state / data    │
              └────────┬─────────┘
                       │ props.children / render prop / named slots
              ┌────────┴────────┐
              ▼                 ▼
       ┌────────────┐   ┌────────────┐
       │  Region A  │   │  Region B  │
       │ (display)  │   │ (list/form)│
       └────────────┘   └────────────┘
                       │
              Optional: shared implicit state via small Context

Render props suit “same logic, different UI”; when children is a function, type the parameters explicitly to avoid implicit any in generated code.

Performance Optimization, Error Boundaries, and Suspense

// useMemo / useCallback: avoid expensive recalculations and unstable references
function ProductList({ products, filter }: Props) {
  // ✅ filteredProducts recalculated only when products or filter changes
  const filtered = React.useMemo(
    () => products.filter(p => p.category === filter),
    [products, filter]
  );

  // ✅ handleClick is stable — won't cause unnecessary child re-renders
  const handleClick = React.useCallback((id: string) => {
    router.push(`/product/${id}`);
  }, []);  // depends on no component state, so deps array is empty

  return <List items={filtered} onItemClick={handleClick} />;
}

// Error Boundary: catch render errors in the component tree
class ErrorBoundary extends React.Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    logger.error(error, info.componentStack);  // report to monitoring service
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

// Suspense + React.lazy: code-split heavy components for faster initial load
const DataChart = React.lazy(() => import('./DataChart'));

function Dashboard() {
  return (
    <ErrorBoundary fallback={<ErrorFallback />}>
      <React.Suspense fallback={<Skeleton />}>
        <DataChart />
      </React.Suspense>
    </ErrorBoundary>
  );
}
  • Dependency arrays: use exhaustive-deps for useEffect/useMemo/useCallback; missing deps cause stale closures, while inline objects/arrays in deps cause extra runs—stabilize references first.
  • Effect purpose: synchronize with external systems (subscriptions, DOM, non-React stores); if an event handler, derived value, or key reset solves it, do not use an effect.
  • Cleanup: unsubscribe and clear timers in cleanup; Strict Mode in dev may double-mount effects to surface missing cleanup.
  • Performance hooks: do not wrap everything in useMemo/useCallback by default; add when child memo depends on referential equality or when values are deps of other Hooks, and comment why.
Stale closure: if an effect always sees old state, check the dependency list first; for callbacks that need the latest state, use functional updates setState(s => …) or useRef for the latest handle (pick per scenario).

In-page tools: dependency explainer and Hooks quiz

The script below runs only on this page to clarify useEffect dependency semantics and reinforce Hook rules (not a linter replacement).

useEffect dependency array explainer

Enter comma-separated dependency names (same as your source array), e.g. userId, query, options. Enter [] alone for an empty array.

Hooks rules mini-quiz (4 questions)

Pick one answer per question; submit to see score and short hints.

1. You may call useState inside an if branch as long as it runs “most of the time.”
2. A useEffect cleanup runs before the next effect run and again when the component unmounts.
3. Putting a fresh object literal like { count: 1 } directly in the dependency array usually makes the effect run every render.
4. A custom Hook may call other Hooks inside it.

SKILL outline

---
name: react-patterns-hooks
description: Implement React features with Hook rules and data-fetch patterns
---
# Rules
1. Follow the Rules of Hooks: call Hooks only at the top level and only in function components or custom Hooks
2. Always satisfy exhaustive-deps: all reactive variables in useEffect/useMemo/useCallback deps arrays must be listed
3. Prevent stale closures: use functional setState(s => …) or a useRef for the latest value in event callbacks
4. Add useMemo/useCallback only when child memo depends on stable reference equality—not everywhere by default
5. Split read and dispatch into separate Contexts to prevent unnecessary re-renders from dispatch updates

# Steps
1. Identify reusable async state logic → extract useAsync Custom Hook; include cancellation and race-condition guard
2. Wrap timer-based debounce/throttle logic in a Custom Hook (useDebounce/useThrottle) and return a stable ref
3. For multi-field or complex state, use useReducer with a pure reducer instead of multiple useState calls
4. Provide state context and dispatch context separately; export typed consumer Hooks (useCartState / useCartDispatch)
5. Wrap expensive derived calculations in useMemo; wrap event callback props passed to React.memo children in useCallback
6. Place an ErrorBoundary at each data-fetching boundary; implement componentDidCatch to report errors to the monitoring service
7. Code-split heavy components (charts, editors) with React.lazy + Suspense; provide a skeleton fallback UI
8. Effects must return a cleanup function; when the async effect may be stale, use a cancelled flag or AbortController
9. In App Router: default to Server Components; mark as 'use client' only when hooks/browser APIs are required
10. Test Hooks with renderHook from @testing-library/react; test async behavior with act and waitFor

Back to skills More skills