Local State Exercise

March 20, 20252 min read

The best way to internalise the "push state down" rule is to feel the difference with your own hands — first watching everything flash in DevTools, then watching only the relevant components flash after the fix.

The Setup

Take any component where all state lives at the top level. The signature of the problem:

  • A parent component with multiple useState calls
  • Those state values passed as props through components that don't use them
  • Every state change triggers a full re-render of the tree

If you enable "Highlight updates when components render" and see the entire page flash when you type in a single input, you've found one.

The Exercise

Step 1 — Measure. Record in the Profiler before touching anything. Note the total render time and which components appear in the flame graph for a single keystroke.

Step 2 — Identify. Which state only affects a specific subtree? An input's draft value doesn't need to live in the root component if nothing outside the form reads it.

Step 3 — Move it. Cut the useState from the parent. Paste it into the component that actually needs it. Delete the props that were threading it through.

Step 4 — Measure again. Record the same interaction. Compare the flame graphs.

What Good Looks Like

After the refactor, the root component often ends up looking like this:

TSX
function App() { return ( <div> <Header /> <CreateThought /> {/* owns its own draft state */} <ThoughtList /> {/* doesn't know CreateThought exists */} </div> ); }

A root component with no props being passed down is a healthy sign. Each subtree owns what it needs. Changes in one don't cascade into others.

The Numbers in Context

After pushing state down, you might see render times like 1–2ms for an input change. The browser paints at 60fps — that's a 16.6ms frame budget. A 1.3ms render means you have 15ms to spare.

At that point, stop optimising. It doesn't matter how many times something flashes in DevTools if the total time is imperceptible. You cannot see something that takes less than a single frame.

The goal isn't zero re-renders. It's correct re-renders that stay within budget.

The Pattern to Watch For

Pushing state down often reveals a second problem: callback functions defined in the parent and passed as props.

TSX
// Even after moving state down, if handlers are defined inline: function Counter() { const [count, setCount] = useState(0); const handleIncrement = () => setCount(c => c + 1); // new function every render const handleReset = () => setCount(0); // new function every render return <CounterDisplay onIncrement={handleIncrement} onReset={handleReset} />; }

These functions are redefined on every render, which means CounterDisplay always receives new prop references — defeating any React.memo you might add. That's what useCallback solves, and it's the next topic.

Practice

0/5 done