In a React 19 app, a drag interaction wraps every pointermove state update in `flushSync` so the dragged element keeps up, and the UI now stutters badly. Explain why that hurts, and what you would do instead.
answer
- forced commit per event, at pointer rate
- blocking the thread that paints
- batched updates already land before paint
- transient gesture value belongs in a ref
- one flushSync per gesture, not per move
basics
~20 sEach flushSync forces a full synchronous render and DOM commit inside the pointermove handler, dozens of times a second, blocking the main thread and defeating batching. Remove it and either let React batch normally or drive the moving element imperatively through a ref.
solid answer
~50 s`flushSync` is not a way to make React faster — it removes React's ability to schedule. Every pointermove event now performs a blocking render pass and commit before the handler returns, at pointer event rate, so the main thread spends the frame budget in React instead of letting the browser paint. It also breaks batching with any other updates in the same handler, which can raise the total number of commits rather than lowering it. The first fix is simply deleting it: React already commits before the browser paints, so a normally batched update is on screen in the same frame. If the drag is still expensive, the real problem is render cost, and the fix is to stop routing every pixel through React state — keep the transient position in a ref and write the transform to the node directly, then commit the final position to state on pointerup so the rest of the tree re-renders once.
code
jsx · 32 linesimport { useRef, useState } from 'react';
export default function Draggable() {
const boxRef = useRef(null);
const originRef = useRef(0);
const [x, setX] = useState(0);
function onPointerDown(e) {
originRef.current = e.clientX - x;
e.currentTarget.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
// no React state during the gesture: no renders at all
boxRef.current.style.transform = `translateX(${e.clientX - originRef.current}px)`;
}
function onPointerUp(e) {
setX(e.clientX - originRef.current); // one render, at the end
}
return (
<div
ref={boxRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{ transform: `translateX(${x}px)`, width: 80, height: 80 }}
/>
);
}go deeper
Know that flushSync forces React to update the DOM immediately and is an escape hatch, not a speed-up, so calling it on every mouse or pointer move is a mistake.
Explain the mechanism: each call performs a blocking render and commit inside the handler, breaks batching with other updates in the same event, and competes with the browser for the frame budget.
Separate scheduling problems from render-cost problems: profile first, remove the forced commits, keep transient gesture values in refs and commit once at the end, and reserve a single flushSync for a real imperative handoff.
Set the boundary as policy: transient interaction state stays out of the render loop, forced synchronous commits are exceptions that need a named reason, and the codebase should not accumulate opt-outs that pin it to synchronous rendering.
## What the code is actually doing Pointer move events fire fast — tens to well over a hundred per second on a high-rate pointer, and browsers coalesce them precisely because handling each one is expensive. Wrapping the state update in `flushSync` turns every one of those events into a full React cycle: render the affected components, diff, commit to the DOM, run layout effects, all synchronously inside the handler before it returns. That is the stutter. The main thread is the same thread that must lay out and paint the frame; if React consumes the frame budget doing forced renders, the browser misses frames and the drag looks worse than it did with plain batching. The developer added `flushSync` to make the element feel more responsive and got the opposite, which is the diagnostic signature of this bug. ## Why the intuition was wrong The assumption behind the code is that a normal state update is "late" — that without forcing it, the DOM lags behind the pointer by a frame or more. That is not how React behaves. When an update is queued in an event handler, React renders and commits **before the browser paints the next frame**. The user never sees a frame where the update was queued but not applied. So `flushSync` buys nothing visually here; it only takes away. What it takes away is concrete: - **Batching with the rest of the handler.** If the handler also updates a hover target or a guide line, those updates would have shared one render pass. Now the `flushSync` one commits on its own and the others commit separately. - **Scheduling freedom.** React cannot yield, reorder, or coalesce this work against anything else in the frame; the render happens now, at the cost of whatever else the frame needed. - **Coalescing.** A forced commit per event means one commit per event, where batching would let several rapid updates collapse into fewer passes. ## The fix, in order **1. Delete the `flushSync`.** Measure again. In many drags this alone restores smoothness, because the only thing wrong was the forced synchronous work. **2. If it is still janky, the problem is render cost, not scheduling.** A drag that re-renders a large subtree on every move will be slow whether or not you force the commit. Two structural moves help: *Keep the transient value out of React state.* The position during a drag is ephemeral — nothing outside the dragged element needs to re-render for it. Store it in a ref and write it straight to the node: ```jsx function handlePointerMove(e) { const x = e.clientX - originRef.current; boxRef.current.style.transform = `translateX(${x}px)`; } function handlePointerUp(e) { setPosition(e.clientX - originRef.current); // one render, at the end } ``` This is a legitimate imperative escape hatch: React owns the committed layout, you own a single style property for the duration of the gesture, and state receives the final value once. Zero renders during the drag beats any amount of tuning of the render path. *Shrink what re-renders.* If the position genuinely must live in state, make the component that holds it as small as possible so a move re-renders one node and not the whole editor — pushing state down rather than memoizing a large tree around it. **3. Keep `flushSync` for what it is for.** There is still a legitimate `flushSync` in a drag implementation: a one-off at the *start* or *end* of the gesture where imperative code must observe the committed DOM — you insert a placeholder element and must immediately measure or scroll relative to it. One call per gesture is fine; one call per event is the bug. ## How to present this in an interview Name the mechanism first (a forced synchronous render and commit per event, at pointer rate, on the thread that paints), then correct the underlying misconception (batched updates already land before paint), then give the structural fix (transient gesture state does not belong in React state) and the residual legitimate use. Showing that you distinguish *scheduling* problems from *render cost* problems is the point of the question — reaching for `flushSync` conflates them.
- The developer insists the drag lagged without flushSync. What would you actually measure before agreeing?Record the interaction in the browser performance profiler and look at where the frame budget goes. If long React render tasks dominate, the cost is in the render path and flushSync makes it worse. If frames are fine and the element still trails the pointer, the culprit is usually work done per event — expensive layout reads, or state that re-renders a large subtree — not the moment React commits.
- Is it ever acceptable to keep one flushSync in a drag implementation?Yes, at a gesture boundary. If dropping an item inserts a node and imperative code must immediately act on that committed node — scroll to it, focus it, hand it to a library — one forced commit is the supported way. The rule is per gesture, not per event: a single blocking render on pointerup is invisible, sixty per second is the stutter.
- Why is writing to element.style from a pointer handler acceptable here when direct DOM mutation is normally discouraged?Because the property is transient and exclusively owned by the gesture: no React render outputs a competing transform while the drag is in flight, and the final value is committed to state on release, after which React is authoritative again. The rule it must not break is that React's rendered output and the live DOM disagree once the interaction ends.
saying these in an interview costs you the question
- Says flushSync makes React updates render faster
- Assumes batched updates are painted a frame late
- Adds memoization instead of removing the per-event commit
- Puts every pixel of a drag through useState
- Calls flushSync inside a loop to force intermediate frames