frontend · level 3

React Patterns

Composition, hooks, and the useEffect footguns.

150 XP

React Patterns

The React component model is a function from state to UI. Most problems come from fighting that model instead of working with it.

Analogy

A React component is like a Lego brick. Each brick has a fixed shape (the component's props and output), but what matters is that bricks snap together — you build a spaceship by combining a cockpit piece, a wing piece, and an engine piece. You don't carve a new figurine from a block of wood every time you want something slightly different. When a React component tries to handle "everything a card could be" with fifteen optional props and deep if-branches, that's whittling — and the next requirement always makes the knife slip. Composition means handing the caller a socket and letting them snap in whatever they want.

Composition over inheritance

React has no class inheritance hierarchy to extend. Components compose by accepting children and other components as props.

// Bad — tight coupling, hard to reuse
function UserCard() {
  return (
    <div>
      <Avatar />  {/* hardcoded inside */}
      <UserName />
    </div>
  );
}

// Good — slot pattern lets callers customise
function Card({ children }: { children: React.ReactNode }) {
  return <div className="card">{children}</div>;
}

// Caller controls what goes inside
<Card>
  <Avatar src={user.avatar} />
  <UserName name={user.name} />
</Card>

This pattern — passing components as children or named props — is more flexible than any inheritance hierarchy.

Custom hooks

Extract stateful logic into functions whose names start with use. This keeps components focused on rendering.

function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const update = () =>
      setSize({ width: window.innerWidth, height: window.innerHeight });
    window.addEventListener("resize", update);
    update();
    return () => window.removeEventListener("resize", update);
  }, []);

  return size;
}

The hook is testable in isolation, composable with other hooks, and reusable across components.

The three useEffect footguns

1. Stale closures

A function defined inside a component captures the values of variables at the time it runs. If useEffect has an empty dependency array [], it captures those values at mount and never updates.

// Bug: count is always 0 inside the interval
useEffect(() => {
  const id = setInterval(() => console.log(count), 1000);
  return () => clearInterval(id);
}, []); // empty deps = stale closure

// Fix: include count in deps, or use a ref

2. Infinite effect loop

If an effect sets state, and that state is in the dependency array, every render triggers the effect — which sets state — which triggers a render.

// Bug: re-renders forever
useEffect(() => {
  setData(transform(data));
}, [data]); // data updates → effect runs → data updates → …

// Fix: compute the derived value during render — no effect needed
const transformed = transform(data);

3. Derived state as an effect

The most common mistake: initialise state from a prop, then keep it in sync via an effect.

// Bug: name lags one render behind; also an effect on every prop change
const [name, setName] = useState(props.initialName);
useEffect(() => {
  setName(props.initialName);
}, [props.initialName]);

// Fix option 1: compute during render (if truly derived)
const displayName = props.initialName.trim();

// Fix option 2: add key on the parent so React resets the whole component
<NameField key={userId} initialName={user.name} />

Lifting state up

When two sibling components need to share state, move the state to their closest common ancestor and pass it down as props.

function Parent() {
  const [value, setValue] = useState("");
  return (
    <>
      <Input value={value} onChange={setValue} />
      <Preview value={value} />
    </>
  );
}

When to use context

Context is not a performance optimisation — every consumer re-renders when context value changes. Use it for slow-moving values: authentication state, theme, locale.

For frequently-changing data (search query, form fields), lift state to the nearest shared ancestor and pass props.

useMemo and useCallback cost

Both come with overhead: a dependency comparison on every render, a closure allocation, and cache storage. They pay off only when:

  • useMemo: the computation is genuinely expensive (measured, not assumed).
  • useCallback: the function is passed to a child wrapped in React.memo.

Wrapping every function in useCallback out of habit makes code harder to read with no measurable benefit.