← Notes

Notes · August 2026 · React, performance

not everything belongs in state.

I built a small Hacker News reader with three columns: the story list, the article, and one comment thread at a time. Between the last two sits a draggable divider. It’s the least glamorous piece of the app, and it forced the sharpest thinking about where state lives.

The obvious wiring

React has one story for interaction: events update state, state renders the view. For a divider, that reads as: pointermove computes a new split ratio, setState stores it, the columns re-render at their new widths. Correct, idiomatic, and it puts a full render-and-commit cycle behind every twitch of the hand.

pointermove fires at the display’s rate: 60 events a second on most screens, 120 or more on recent ones. And the subtree that reads the ratio is the two biggest components on the page, an article view and a comment thread. So the cost of one drag frame isn’t “set a width.” It’s “ask the article and every visible comment whether they’d render differently now.” The answer is always no. The question is what it costs to keep asking.

What a frame can afford

A 60Hz display gives you 16.7ms per frame; 120Hz gives you 8.3ms. The browser spends part of that on its own pipeline: style, layout, paint, composite. Whatever remains is the budget for your render, and a comment thread’s render cost is variable: long threads, cold caches, whatever memoization missed. When the work overruns the budget, the frame drops. A dropped frame during a drag isn’t an abstraction. The handle visibly stops following the hand, then jumps. Users can’t name it; they just call the app heavy.

Three kinds of state

Working through the divider ended up sorting the whole app’s state into three tiers.

State the UI shows at rest. Which story is selected, which thread is open. This is useState territory: when it changes, the view should re-render, and React should know.

State that is a side effect on the DOM. Each story’s scroll position, restored when you navigate back to it. Restoring scroll re-renders nothing, so it lives in a ref keyed by story id. Putting it in useState would buy re-renders nobody asked for.

State that changes every frame while a gesture is live. The divider’s ratio, mid-drag. Its rate is the input device’s rate, not the decision rate of the interface, and for the duration of the gesture the DOM itself is the right owner.

The ratio is the interesting one because it moves between tiers. At rest it’s tier one: the layout renders from it. While a finger is down it’s tier three. The mistake is treating it as tier one at tier three’s frequency.

The fix

Simplified from the app:

const ratioRef = useRef(0.5);            // truth during the drag
const [ratio, setRatio] = useState(0.5); // truth at rest

function onPointerMove(e) {
  const { startX, startRatio, width } = dragSession;
  const next = clamp(startRatio + (e.clientX - startX) / width);
  ratioRef.current = next;
  columnEl.style.flex = `0 0 ${next * 100}%`; // one style write, no render
}

function onPointerUp() {
  window.removeEventListener("pointermove", onPointerMove);
  window.removeEventListener("pointerup", onPointerUp);
  setRatio(ratioRef.current); // tell React once, when it matters
}

During the drag, each pointermove costs one clamp and one style write; the browser flexes two boxes and composites. No component function runs. On pointerup, a single setState folds the result back into React, and the tree’s picture of the world is true again. The window where the DOM and React disagree lasts exactly as long as a finger is down, and one line closes it.

Feel it

Below, the same divider twice. Both panes hold the same rows. The left routes every move through a rebuild of its rows before setting the width, which is what an unmemoized subtree re-render amounts to; the right writes the width and touches nothing else. Drag both, then raise the row count and drag again. The counters are measured live, nothing staged.

One honesty note: the left pane rebuilds real DOM rows, which is more work than React’s reconciler (the diff that decides what actually changed) would do for rows that didn’t change. Read the slider as “what your subtree’s render costs,” however it gets spent. The shape is the point: the left side’s cost scales with the tree and rides the pointer’s event rate. The right side’s cost is constant.

The tradeoff

Escaping the render loop creates two sources of truth while the gesture is live, and that has teeth. If unrelated state updates mid-drag, React re-renders and stamps the stale rest-state width right back onto the column. In this app nothing else updates during a drag, and the next pointermove overwrites the stomp within a frame, so the race is short and acceptable. In an app where things do update mid-gesture — incoming data, timers, collaboration — move the escaped value somewhere React doesn’t own at all, like a CSS variable on a container, so a re-render can’t clobber it.

The same app also resizes with arrow keys, and that path goes through setState on every keypress. Deliberately. Key repeat arrives at maybe thirty events a second, and each press is a decision, not a transient; the render loop absorbs it without dropping anything. The escape hatch isn’t for “resizing.” It’s for input that arrives at frame rate. Judge by rate, not by feature.

The principle

useState is for what the interface shows once it settles. Refs are for facts that change nothing visible. And a live gesture is the DOM’s business until it ends: route it through React at the rate decisions are made, not the rate the pointer moves.