All writing
9 min read

Stop Fighting Re-renders: A Pragmatic Guide to React Performance

memo, useMemo, and useCallback are not magic. Here is the mental model I use to decide when they actually help — and when they just add noise.

ReactPerformanceHooks

Every React codebase I have joined eventually accumulates a layer of useMemo and useCallback calls that nobody can explain. They were added during a performance scare, they never got removed, and now they are just ambient anxiety in the diff. Let me share the model I use to keep this under control.

First, understand what a re-render actually costs

A re-render is React calling your component function again and diffing the result against the previous tree. For most components this is cheap — a few microseconds. The expensive part is almost never the render itself; it is one of three things: a genuinely heavy computation in the render path, a large subtree re-rendering when it did not need to, or an effect firing more often than it should.

So before reaching for memoization, profile. The React DevTools Profiler will tell you which components render and how long they take. If a component renders in 0.3ms, wrapping it in React.memo buys you nothing and costs you a comparison on every render.

The reference-equality trap

The real reason re-renders bite you is reference equality. Every render creates new object and function literals, and any child that depends on those references will re-render or re-run effects. Consider this innocent-looking component:

tsx
function Dashboard({ userId }: { userId: string }) {
  // New object every render — breaks memoized children downstream.
  const filters = { userId, status: "active" };

  return <ExpensiveList filters={filters} />;
}

Even if ExpensiveList is wrapped in React.memo, it re-renders every time Dashboard does, because filters is a brand new object each time. This is where useMemo earns its keep:

tsx
function Dashboard({ userId }: { userId: string }) {
  const filters = useMemo(
    () => ({ userId, status: "active" }),
    [userId],
  );

  return <ExpensiveList filters={filters} />;
}

Now filters keeps a stable reference until userId changes. The memo is not about the cost of creating the object — that is trivial — it is about preserving identity so the memoized child can bail out.

useCallback follows the same rule

useCallback is just useMemo for functions. It matters only when the function is passed to a memoized child or used as a dependency of another hook. A callback passed straight to a native <button onClick> does not need it — the DOM does not care about reference identity.

tsx
// Pointless: the button re-renders with the parent anyway.
const onClick = useCallback(() => setOpen(true), []);
return <button onClick={onClick}>Open</button>;

// Worth it: handler flows into a memoized, expensive child.
const onSelect = useCallback(
  (id: string) => dispatch({ type: "select", id }),
  [dispatch],
);
return <VirtualizedTable onSelect={onSelect} />;

A decision checklist

  1. Is there a measured performance problem? If not, stop here.
  2. Is the cost in computation or in re-rendering a subtree? Profile to find out.
  3. If computation: memoize the value with useMemo and a precise dependency array.
  4. If subtree re-renders: wrap the child in React.memo, then stabilize the props feeding it with useMemo/useCallback.
  5. Re-profile. If the numbers did not move, revert the change — dead memoization is a liability, not a safety net.

The structural fix you should reach for first

Before any memoization, ask whether you can move state down or lift content up. The cheapest re-render is the one that never happens. If a piece of state only affects a small part of the tree, colocate it there. If a large static subtree sits inside a frequently-updating parent, pass it as children so it keeps its identity:

tsx
function Ticker({ children }: { children: React.ReactNode }) {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setTick((t) => t + 1), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <span>{tick}</span>
      {children /* does NOT re-render when tick changes */}
    </div>
  );
}
Memoization is a tool for preserving identity, not a substitute for good component boundaries. Get the structure right and you will reach for it far less often.

The React 19 compiler is starting to automate a lot of this, and that is genuinely exciting — but the mental model still matters. Understanding why a re-render happens will always beat sprinkling hooks until the profiler turns green.