Escape Hatches
What you'll learn
flushSync: when you truly need the DOM updated before the next line of code runsuseImperativeHandle: exposing a tiny imperative API from a component, and the rules for doing it safelyact(): making tests flush renders and effects before assertionsdangerouslySetInnerHTML: the XSS contract, and the one API to avoid entirely- The meta-rule that tells you when an escape hatch is legitimate
React's guarantees, batching, declarative updates, encapsulated components, exist to keep apps correct and fast. But a small number of real problems require stepping outside those guarantees for one line of code. React provides explicit escape hatches for those moments: APIs that say "I know what I'm giving up, and I mean it." This chapter covers each one, its single legitimate use, and the cost. The name is the design philosophy: a hatch you escape through occasionally, not a door you live in.
flushSync: commit now, not at the end of the tick
Normally, state updates are batched: React schedules them, keeps executing your code, and renders once when the call stack clears (Part 3). flushSync(fn) says: run the updates inside fn, then flush them synchronously, render and commit to the DOM, before returning.
The one legitimate use: you must read the updated DOM immediately after an update. Classic case, scroll a newly added message into view:
import { flushSync } from 'react-dom';
import { useRef, useState } from 'react';
export default function Chat() {
const [messages, setMessages] = useState(['hello']);
const listRef = useRef(null);
function send(text) {
// ❌ Without flushSync: setMessages is merely SCHEDULED.
// scrollIntoView would run against the OLD DOM — the new
// message isn't in it yet, so we'd scroll to stale content.
flushSync(() => {
setMessages((m) => [...m, text]);
});
// ✅ By this line, the DOM already contains the new message.
listRef.current.lastElementChild.scrollIntoView({ behavior: 'smooth' });
}
return (
<>
<ul ref={listRef}>
{messages.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
<button onClick={() => send('new message')}>Send</button>
</>
);
}
What happens:
flushSyncruns its callback: the update is queued.- Before
flushSyncreturns, React rendersChatand commits: the new<li>exists in the real DOM. scrollIntoViewreads that fresh DOM and scrolls correctly.
The costs, stated plainly:
- It breaks batching. Everything pending gets flushed now, and each
flushSyncis its own render + commit. ThreeflushSynccalls in a loop = three commits where batching would have done one. It's a scalpel for one line, not a pattern. - Never during render or from inside an effect that's mid-flush, React will warn or error, because forcing a commit while one is in progress violates the phase model (Part 2). Call it from event handlers and async callbacks only.
- Same family of legitimate uses: measuring an element's new size right after an update, printing the page, focusing a just-rendered input. The tell is always "I need to read the DOM that this update produces."
useImperativeHandle: a tiny, deliberate imperative API
Some components wrap inherently imperative things, a <video> element, a map widget, a canvas. Refs let a parent reach a DOM node, but handing the parent your raw internals invites it to poke everything. useImperativeHandle lets you expose exactly two buttons and nothing else:
import { forwardRef, useImperativeHandle, useRef } from 'react';
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null);
// The parent will see ONLY { play, pause } on its ref —
// not the <video> element, not anything else.
useImperativeHandle(ref, () => ({
play() {
videoRef.current.play();
},
pause() {
videoRef.current.pause();
},
}));
return <video ref={videoRef} src={src} />;
});
export default function Page() {
const playerRef = useRef(null);
return (
<>
<VideoPlayer ref={playerRef} src="/intro.mp4" />
<button onClick={() => playerRef.current.play()}>Play</button>
<button onClick={() => playerRef.current.pause()}>Pause</button>
</>
);
}
What happens:
VideoPlayerrenders a real<video>and keeps its own ref on it, private.useImperativeHandlebuilds the object the parent's ref receives:{ play, pause }, closing over the internal video node.- The parent calls
playerRef.current.play(). It cannot seek, cannot setplaybackRate, cannot remove the element, the surface area is exactly what you exported.
The rules:
- Expose actions, not internals.
play()andfocus()are actions; the raw DOM node is an internal. The moment a parent holds your node, your component no longer controls its own rendering. - Prefer props first. "Is the video playing?" is state and should flow as a prop (
<VideoPlayer playing={...}>), not as imperative calls. Reach for the handle only when the operation is a one-shot command, play, focus, scroll-to, that has no meaningful declarative representation. - Keep the exposed API tiny. Every method you add is a coupling you maintain forever.
act(): flushing work in tests
Tests interact with components outside React's event system, so React doesn't know when to batch and flush. act wraps an interaction and guarantees: by the time it returns, all scheduled renders and effects have been committed. Assertions after act see the settled UI.
import { act } from 'react';
import { createRoot } from 'react-dom/client';
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
// Any update triggered inside act() is rendered, committed,
// and its effects run BEFORE the next line executes.
act(() => {
root.render(<Counter />);
});
expect(container.textContent).toBe('Count: 0');
act(() => {
container.querySelector('button').click();
});
expect(container.textContent).toBe('Count: 1');
What happens: without act, the click's state update might still be pending when the assertion runs, flaky tests that pass on your machine and fail in CI. act makes "interact, then assert" deterministic. (Testing libraries wrap their helpers in act for you; you write it by hand only when driving updates outside those helpers, timers, promises, direct root renders.)
dangerouslySetInnerHTML: the XSS contract
Sometimes you receive HTML from outside, a CMS, a markdown renderer, and must insert it as markup:
import DOMPurify from 'dompurify';
function Article({ htmlFromCms }) {
// ✅ Sanitize FIRST. Always. No exceptions for "trusted" sources.
const safe = DOMPurify.sanitize(htmlFromCms);
return <div dangerouslySetInnerHTML={{ __html: safe }} />;
}
The contract, in one paragraph because it deserves gravity: whatever string you pass becomes real HTML in the page, with no escaping. If that string contains <script> or an onerror attribute and it came from user input, a network response, or literally anywhere you don't fully control, you have handed an attacker code execution in your users' sessions, that's XSS. React's normal JSX escaping is one of its quiet security features; this API disables it on purpose (the awkward name is the warning label). Rule: always sanitize with a vetted library, immediately before insertion, and never build the string yourself with concatenation.
findDOMNode: the hatch that's closed
You may meet findDOMNode(component) in ancient code: it takes a component instance and returns its DOM node. It's deprecated and removed from modern React, it breaks component encapsulation (reaching through a component to its rendered output), and it blocks internal optimizations. Use a ref on the element or component instead. Mentioned here only so you can recognize it in legacy code and replace it.
The meta-rule
Every escape hatch trades away a guarantee:
| Hatch | Guarantee you give up | Legitimate trigger |
|---|---|---|
flushSync | Batching | Must read the just-updated DOM |
useImperativeHandle | Declarative data flow | One-shot commands: play, focus, scroll |
act | (none, it's test infrastructure) | Flushing work before assertions |
dangerouslySetInnerHTML | Automatic escaping | Pre-sanitized external HTML only |
So the diagnostic is simple: needing an escape hatch occasionally is normal; needing them often means your architecture is fighting the framework. A codebase full of flushSync is re-implementing batching badly. Imperative handles everywhere means state is being driven by commands instead of props. When you reach for a hatch for the third time in a week, stop and ask what the declarative version of the problem looks like.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
flushSyncmakes state updates "synchronous" in general. Reality: updates were never async, they're batched (Part 3).flushSyncforces an early flush of one batch; it doesn't change how scheduling works elsewhere. - Misconception:
flushSyncin a loop is a performance trick. Reality: the opposite, each call is a full synchronous render and commit. It's for correctness of immediate DOM reads, and it costs performance. - Misconception: Refs +
useImperativeHandleare the idiomatic way to control children. Reality: props are. Handles are for one-shot commands with no declarative form; if you're syncing values through a handle, it should have been a prop. - Misconception:
actis test-framework magic you must configure. Reality: it's a React export that flushes work; most testing libraries already wrap their interactions in it. - Misconception: Content from your own CMS is safe to inject raw. Reality: sanitize anyway, CMS content is user input with extra steps, and the cost of being wrong is session-stealing XSS.
- Misconception: Escape hatches are "advanced React" you should use to show expertise. Reality: they're debt instruments. Seniority shows in how rarely you need them.
Why it works this way
- Guarantees are only useful if breaking them is explicit. Batching and declarative flow deliver their benefits precisely because violations are rare, visible, and greppable,
flushSyncanddangerouslySetInnerHTMLare names designed to be noticed in code review. - Immediate DOM reads fundamentally can't be batched. Scrolling to a node that doesn't exist yet is impossible; the only honest API is "finish rendering first." Rather than weaken batching globally, React sells you an exception per call.
- Encapsulation needs a membrane, not a door.
useImperativeHandleexists so that imperative interop doesn't dissolve component boundaries, the parent gets a remote control with two buttons, not the keys to the house. - Security failures get ugly names on purpose.
dangerouslySetInnerHTMLcould have beeninnerHTML. The name is a permanent inline code review comment.
Try it yourself
- Build the
Chatexample twice: once with plainsetMessagesthenscrollIntoView, once withflushSync. LoglistRef.current.lastElementChild.textContentright after the update in both versions and observe the stale read withoutflushSync. - Call
flushSyncinside auseEffectand inside a render body; read the warnings React gives you and connect them to the phase model. - Extend
VideoPlayerto exposeseekTo(seconds). Then try to do "autoplay when a prop is true" through the handle, feel how awkward it is, and redo it with aplayingprop. Which one synced better? - Write the
Countertest snippet, then delete the secondactand run it under fake timers. Watch the assertion intermittently fail; restoreact.
Recap
flushSync(fn): flush the enclosed updates synchronously, render and commit before returning. Legitimate only for reading the just-updated DOM (scroll, measure, focus); breaks batching; never during render or mid-effect.useImperativeHandle: expose a tiny command API (play,focus) over a ref. Actions, not internals, and props first whenever the thing is really state.act(): wraps test interactions so renders and effects settle before assertions. Deterministic tests, no production meaning.dangerouslySetInnerHTML: disables React's escaping, sanitize with a vetted library every time, or you're shipping XSS.findDOMNodeis dead; use refs.- Meta-rule: hatches trade guarantees for control. Occasional use is engineering; frequent use is the architecture fighting the framework.