React Patterns

Updated 2026-06-24

Senior-level patterns and pitfalls for React 19. Assumes you know the basics (useState, useEffect, JSX) — the focus here is on the hooks, composition techniques, and concurrent features you reach for in larger apps, plus the tradeoffs that decide which to use. Examples target React 19; version-specific APIs are called out inline.

Custom hooks — extract stateful logic

A custom hook is any function named useXxx that calls other hooks; it shares logic, not state — each call site gets its own independent state.

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const;
}

Bound by the Rules of Hooks: call them at the top level, in the same order every render — never in conditionals or loops. The eslint-plugin-react-hooks rule is non-negotiable; React tracks hook state by call order, so a conditional hook corrupts it.

import { useState, useCallback } from "react";
import { createRoot } from "react-dom/client";

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle];
}

function Demo() {
  const [on, toggle] = useToggle();
  return <button onClick={toggle}>{on ? "ON" : "OFF"} — click to toggle</button>;
}

createRoot(document.getElementById("root")).render(<Demo />);
Output

useReducer — state transitions as data

Prefer useReducer over multiple useState calls when the next state depends on the previous one or several fields change together — it centralizes transitions and is easier to test.

type Action = { type: 'inc' } | { type: 'set'; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'inc': return state + 1;
    case 'set': return action.value;
  }
}
const [count, dispatch] = useReducer(reducer, 0);
dispatch({ type: 'inc' });

The reducer must be pure — no fetches or mutations — which is exactly what makes the logic unit-testable in isolation.

import { useReducer } from "react";
import { createRoot } from "react-dom/client";

function reducer(state, action) {
  if (action.type === "inc") return state + 1;
  if (action.type === "dec") return state - 1;
  return 0; // reset
}

function Demo() {
  const [count, dispatch] = useReducer(reducer, 0);
  return (
    <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
      <button onClick={() => dispatch({ type: "dec" })}>−</button>
      <strong style={{ fontSize: 18 }}>{count}</strong>
      <button onClick={() => dispatch({ type: "inc" })}>+</button>
      <button onClick={() => dispatch({ type: "reset" })}>reset</button>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Demo />);
Output

Context — and its re-render trap

createContext shares values without prop-drilling, but every consumer re-renders whenever the provider's value changes — and a new object literal as value changes identity every render.

const ThemeContext = createContext<Theme | null>(null);

// Memoize the value so unrelated re-renders don't cascade
const value = useMemo(() => ({ dark, toggle }), [dark]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

For frequently-changing state, split state and dispatch into separate contexts (or reach for a store like Zustand) so a value change doesn't re-render components that only dispatch. Context is for low-frequency global data, not a general state manager.

import { createContext, useContext, useState } from "react";
import { createRoot } from "react-dom/client";

const ThemeContext = createContext("light");

function Label() {
  const theme = useContext(ThemeContext);
  return <p>Theme from context: <strong>{theme}</strong></p>;
}

function Demo() {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext.Provider value={theme}>
      <Label />
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        Toggle theme
      </button>
    </ThemeContext.Provider>
  );
}

createRoot(document.getElementById("root")).render(<Demo />);
Output

Memoization — and the React Compiler

Because React re-renders subtrees by default, memo, useMemo, and useCallback prevent wasted work and keep object/function identities stable across renders.

const Row = memo(function Row({ item }: { item: Item }) { /* ... */ });
const sorted = useMemo(() => items.sort(byName), [items]);
const onPick = useCallback((id: string) => select(id), []);

useMemo is a performance hint, not a guarantee — React may discard it, so never put load-bearing logic inside. The React Compiler (React 19 era) auto-inserts this memoization, steadily retiring the hand-written boilerplate; understand the model anyway for debugging and for code the compiler can't prove safe.

Refs for mutable values

useRef holds a mutable .current that persists across renders without triggering one — for timer ids, previous values, or the "latest value" pattern that dodges stale closures in long-lived callbacks.

const latest = useRef(value);
useEffect(() => { latest.current = value });

useEffect(() => {
  const id = setInterval(() => console.log(latest.current), 1000);
  return () => clearInterval(id); // always reads the freshest value
}, []);

useId — stable SSR-safe ids

useId generates an id that matches between server and client render, avoiding hydration mismatches — use it for label/aria wiring, never for list keys.

const id = useId();
return <><label htmlFor={id}>Email</label><input id={id} /></>;

useTransition and useDeferredValue

Concurrent React lets you mark updates as non-urgent so a slow render doesn't block typing. useTransition wraps the state update; useDeferredValue defers a derived value.

const [isPending, startTransition] = useTransition();
startTransition(() => setQuery(input)); // keeps the input responsive
const deferredQuery = useDeferredValue(query); // lags behind under load
const results = useMemo(() => search(deferredQuery), [deferredQuery]);

Reach for these when an expensive filter/render makes input feel janky — they trade slight result staleness for a responsive UI.

import { useState, useTransition } from "react";
import { createRoot } from "react-dom/client";

const items = Array.from({ length: 3000 }, (_, i) => "Item " + i);

function Demo() {
  const [query, setQuery] = useState("");
  const [list, setList] = useState(items);
  const [isPending, startTransition] = useTransition();

  function onChange(e) {
    const v = e.target.value;
    setQuery(v);
    startTransition(() => setList(items.filter((x) => x.includes(v))));
  }

  return (
    <div>
      <input value={query} onChange={onChange} placeholder="Filter 3000 items…" />
      <p>{isPending ? "updating…" : list.length + " matches"}</p>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Demo />);
Output

useSyncExternalStore — subscribe to external state

The correct way to read from a store outside React (a browser API, a vanilla store) without tearing during concurrent rendering. The third argument supplies a server snapshot for SSR.

const width = useSyncExternalStore(
  (cb) => { addEventListener('resize', cb); return () => removeEventListener('resize', cb); },
  () => window.innerWidth, // client snapshot
  () => 1024,              // server snapshot
);

Error boundaries

Only a class component can catch render errors in its subtree; there is still no hook equivalent. Pair it with a reset key so recovery is possible.

class ErrorBoundary extends React.Component<Props, { error: Error | null }> {
  state = { error: null };
  static getDerivedStateFromError(error: Error) { return { error }; }
  componentDidCatch(error: Error, info: ErrorInfo) { report(error, info); }
  render() { return this.state.error ? this.props.fallback : this.props.children; }
}

Error boundaries catch render/lifecycle errors only — not event handlers or async code; handle those with try/catch.

Suspense and lazy

Suspense shows a fallback while a lazy component or a suspending data read resolves — coordinating one loading state for a whole subtree.

const Chart = lazy(() => import('./Chart'));

<Suspense fallback={<Spinner />}>
  <Chart />
</Suspense>

Resetting state with key

Changing a component's key unmounts and remounts it, discarding all its state — the idiomatic way to reset a form or re-init a component when an identity changes.

<UserForm key={userId} user={user} /> // fully resets when userId changes

This is cleaner than an effect that imperatively resets state on a prop change, and it's the React-blessed answer to "how do I reset state when a prop changes."

import { useState } from "react";
import { createRoot } from "react-dom/client";

function Field() {
  const [text, setText] = useState("");
  return (
    <input value={text} onChange={(e) => setText(e.target.value)}
      placeholder="type, then reset" />
  );
}

function Demo() {
  const [k, setK] = useState(0);
  return (
    <div style={{ display: "flex", gap: 8 }}>
      <Field key={k} />
      <button onClick={() => setK(k + 1)}>Reset field</button>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Demo />);
Output

Controlled vs uncontrolled

A controlled input drives its value from state (predictable, validate-on-change); an uncontrolled input keeps its own DOM state and you read it via a ref (less code, fewer re-renders).

// Controlled — React owns the value
<input value={name} onChange={(e) => setName(e.target.value)} />

// Uncontrolled — the DOM owns it; read on submit
<input ref={inputRef} defaultValue="" />

Default to controlled for anything you validate or react to live; reach for uncontrolled for large simple forms where per-keystroke re-renders cost more than they're worth.

use() — read promises and context (React 19)

The use API unwraps a promise (suspending until it resolves) or reads context — and unlike hooks, it may be called conditionally.

function Comment({ promise }: { promise: Promise<string> }) {
  const text = use(promise); // suspends until resolved
  const theme = use(ThemeContext); // also reads context
  return <p className={theme}>{text}</p>;
}

Form actions (React 19)

React 19 turns <form action={fn}> into a first-class data-mutation primitive. useActionState tracks the result and pending state of an action.

const [state, action, pending] = useActionState(submitFn, initialState);
return <form action={action}><button disabled={pending}>Save</button></form>;

useFormStatus reads the parent form's pending state from a nested component without prop-drilling; useOptimistic shows an optimistic result while the action is in flight.

const [optimistic, addOptimistic] = useOptimistic(items, (cur, next) => [...cur, next]);

You might not need an effect

The most common senior correction in React reviews: data derived from props/state should be computed during render, not synced via an effect. Effects are for synchronizing with external systems (network, subscriptions, non-React widgets).

// ❌ effect + state to derive a value
useEffect(() => setFullName(`${first} ${last}`), [first, last]);

// ✅ just compute it
const fullName = `${first} ${last}`;

Each unnecessary effect adds a render pass, a stale-closure risk, and a dependency array to maintain.

StrictMode

In development, StrictMode deliberately double-invokes renders and mounts/unmounts/remounts every component to surface missing effect cleanup and impure renders. It does nothing in production.

<StrictMode><App /></StrictMode>

If a feature breaks only under StrictMode's double-mount, the code has a latent bug (usually missing cleanup or a render side effect) — fix the code, don't remove StrictMode.

Security — dangerouslySetInnerHTML

JSX escapes interpolated values by default, so {userInput} is XSS-safe. dangerouslySetInnerHTML opts out of that protection — only ever feed it sanitized HTML.

import DOMPurify from 'dompurify';

// DANGER: raw user HTML can run <script> / onerror handlers
<div dangerouslySetInnerHTML={{ __html: userBio }} />

// SAFE: sanitize first
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userBio) }} />

A user-controlled href can carry a javascript: URL that executes on click. Validate the protocol before rendering any link or redirect target.

function safeHref(url: string): string {
  const ok = /^(https?:|mailto:|\/)/i.test(url.trim());
  return ok ? url : '#';
}

<a href={safeHref(link)} rel="noopener noreferrer">Visit</a>

Security — serialized state on the server

When you embed server state into HTML (SSR hydration, a __DATA__ script), an unescaped < in the JSON can break out into a <script> tag. Escape it, and never serialize secrets into client-visible state.

const json = JSON.stringify(data).replace(/</g, '\\u003c'); // break out of </script>
// <script id="data" type="application/json">{json}</script>

Treat anything that reaches the client bundle or payload as public — API keys and tokens belong on the server only.