Good to knowAdvanced React APIs · 06

flushSync

Make React update the DOM before the next line runs, so you can focus or scroll to what you just rendered.

On this page · 7 sections
  1. In one minute
  2. The example: a wishlist
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

setState
WishlistName.tsx
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}
What you see
Birthday ideas ✎
Focus: –
Console (recorded)
inputRef.current = null
render (editing: true)
  1. schedulesetEditing(true)The update is queued. React will render after the click handler returns.
  2. eventlog(`inputRef.current = ${inputRef.current}`)The input doesn’t exist yet.
  3. eventinputRef.current?.focus()So there’s nothing to focus.
  4. render phaselog(`render (editing: ${editing})`)Only now does React render and swap the button for the input. Too late: the code that wanted it has finished.

0 / 0
Clicking a wishlist’s name swaps it for an input that should be focused right away (both versions recorded).

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

Common mistakes

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.

Sources

Primary source: react.dev/reference/react-dom/flushSync