In one minute
setState doesn’t change the DOM right away. React queues the update and renders after your event handler returns, so it can batch several updates into one render. Usually that’s what you want. But sometimes the very next line needs the new DOM: to focus an input that’s about to appear, or to scroll to an item you just added. flushSync(() => setState(…)) makes React render and commit before flushSync returns. It’s an escape hatch, because it gives up batching and scheduling for that update.
You’ll be able to: focus or scroll to something you just rendered, and explain why it’s a de-optimization.
1function WishlistName() {2 const [editing, setEditing] = useState(false)3 const inputRef = useRef(null)4 log(`render (editing: ${editing})`)56 function startEditing() {7 setEditing(true)8 log(`inputRef.current = ${inputRef.current}`)9 inputRef.current?.focus()10 }11 return editing12 ? <input ref={inputRef} defaultValue="Birthday ideas" />13 : <button onClick={startEditing}>Birthday ideas ✎</button>14}- schedule
setEditing(true)The update is queued. React will render after the click handler returns. - event
log(`inputRef.current = ${inputRef.current}`)The input doesn’t exist yet. - event
inputRef.current?.focus()So there’s nothing to focus. - render phase
log(`render (editing: ${editing})`)Only now does React render and swap the button for the input. Too late: the code that wanted it has finished.
1function WishlistName() {2 const [editing, setEditing] = useState(false)3 const inputRef = useRef(null)4 log(`render (editing: ${editing})`)56 function startEditing() {7 flushSync(() => setEditing(true))8 log(`inputRef.current = ${inputRef.current}`)9 inputRef.current?.focus()10 }11 return editing12 ? <input ref={inputRef} defaultValue="Birthday ideas" />13 : <button onClick={startEditing}>Birthday ideas ✎</button>14}- render phase
flushSync(() => setEditing(true))flushSyncrenders and commits before it returns. Recorded: the render line is logged inside the click handler. - event
log(`inputRef.current = ${inputRef.current}`)The ref is already attached. - event
inputRef.current?.focus()Focus works.
The example: a wishlist
1. Rename inline, then focus
Clicking the wishlist’s name swaps it for an input, which should be focused right away.
function WishlistName({ sync }: { sync: boolean }) {
const [editing, setEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
log(`render (editing: ${editing})`)
function startEditing() {
log('click: start')
if (sync) {
flushSync(() => setEditing(true))
} else {
setEditing(true)
}
log(`after setEditing: inputRef.current = ${inputRef.current ? '<input>' : 'null'}`)
inputRef.current?.focus()
log(`focused: ${document.activeElement?.tagName.toLowerCase()}`)
}
return editing ? (
<input id="name" ref={inputRef} defaultValue="Birthday ideas" onBlur={() => setEditing(false)} />
) : (
<button id="edit" onClick={startEditing}>Birthday ideas ✎</button>
)
}
With a plain setEditing(true) (sync false):
click: start
after setEditing: inputRef.current = null
focused: button
render (editing: true)
The render line comes last. When the handler asked for inputRef.current, the input didn’t exist yet, so nothing was focused.
With flushSync:
click: start
render (editing: true)
after setEditing: inputRef.current = <input>
focused: input
The render happened inside the click handler, between click: start and the next line, so the ref was attached and focus worked.
2. Add, then scroll to it
function Wishlist({ sync }: { sync: boolean }) {
const [items, setItems] = useState(['Ceramic Mug', 'Desk Lamp'])
const listRef = useRef<HTMLUListElement>(null)
function add(name: string) {
if (sync) {
flushSync(() => setItems([...items, name]))
} else {
setItems([...items, name])
}
const last = listRef.current!.lastElementChild!
log(`last <li> right after the update: ${last.textContent}`)
last.scrollIntoView({ block: 'nearest' })
}
return (
<>
<ul ref={listRef} className="wishlist">
{items.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
<button id="add" onClick={() => add('Notebook Set')}>Add Notebook Set</button>
</>
)
}
Adding “Notebook Set”, without and with flushSync:
last <li> right after the update: Desk Lamp
last <li> right after the update: Notebook Set
Without it, scrollIntoView scrolled to the previous last item.
How it works
- Updates are queued, then rendered together. Inside a click handler, React renders after the handler finishes (Scheduler, Lanes and Batching). Every
setStatein that handler ends up in one render. flushSyncrenders now. It runs your callback, then renders and commits the queued updates synchronously, including layout effects and ref attachment. When it returns, the DOM is up to date.- Why it’s a de-optimization: the work can’t be batched with later updates or split up, it blocks the main thread, and it may also flush other pending updates. If a
Suspenseboundary would suspend during that render, it may show its fallback. - Alternatives: if the element only needs focus when it appears,
autoFocusor a ref callback (ref={(el) => el?.focus()}) does it withoutflushSync. If the code reacts to the DOM after every relevant render, it belongs in useLayoutEffect.
Common mistakes
- Using it to make updates “faster”. It makes them synchronous, which is usually slower overall.
- Calling it while React is already rendering or committing (in a component body, or a layout effect). React warns that it can’t flush then, and the update waits.
- Wrapping many updates in many
flushSynccalls. Each one is a full render and commit.
Interview Q&A
What does flushSync do?
flushSync(callback) runs the callback and makes React apply the resulting updates to the DOM before it returns. The code after it sees the updated DOM.
Why is the DOM not updated right after setState?
React queues updates and renders after the event handler, batching them into one render. Recorded: without flushSync, inputRef.current was null right after setEditing(true), and the render was logged after the handler’s last line.
Give a case where you need it.
Focusing an input that appears because of the same click (inline rename), or scrolling to an item you just added. Recorded: without flushSync, the “last <li>” was still Desk Lamp; with it, Notebook Set.
Why does React call it a de-optimization?
It forces a synchronous render and commit that can’t be batched or interrupted, may flush unrelated pending updates, and can show Suspense fallbacks. Use it only when code outside React needs the DOM immediately.
How is it related to useLayoutEffect?
Both handle “the DOM isn’t updated yet”. flushSync is imperative, inside the handler: update now so my next line sees it. useLayoutEffect reacts after a commit and before paint: read the new layout and adjust.
Related
- useLayoutEffect: read the DOM before paint.
- useImperativeHandle: expose
focus()from a component. - Scheduler, Lanes and Batching: how React batches and schedules updates.
Sources
- react.dev:
flushSync, Queueing a series of state updates - Jules Blom, More than you need to know about ReactDOM.flushSync
- Topic order inspired by Kent C. Dodds’ EpicReact Advanced React APIs workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react-dom/flushSync