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.