Skip to main content

Escape Hatches

What you'll learn

  • flushSync: when you truly need the DOM updated before the next line of code runs
  • useImperativeHandle: exposing a tiny imperative API from a component, and the rules for doing it safely
  • act(): making tests flush renders and effects before assertions
  • dangerouslySetInnerHTML: 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:

  1. flushSync runs its callback: the update is queued.
  2. Before flushSync returns, React renders Chat and commits: the new <li> exists in the real DOM.
  3. scrollIntoView reads that fresh DOM and scrolls correctly.

The costs, stated plainly:

  • It breaks batching. Everything pending gets flushed now, and each flushSync is its own render + commit. Three flushSync calls 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:

  1. VideoPlayer renders a real <video> and keeps its own ref on it, private.
  2. useImperativeHandle builds the object the parent's ref receives: { play, pause }, closing over the internal video node.
  3. The parent calls playerRef.current.play(). It cannot seek, cannot set playbackRate, cannot remove the element, the surface area is exactly what you exported.

The rules:

  • Expose actions, not internals. play() and focus() 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:

HatchGuarantee you give upLegitimate trigger
flushSyncBatchingMust read the just-updated DOM
useImperativeHandleDeclarative data flowOne-shot commands: play, focus, scroll
act(none, it's test infrastructure)Flushing work before assertions
dangerouslySetInnerHTMLAutomatic escapingPre-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

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: flushSync makes state updates "synchronous" in general. Reality: updates were never async, they're batched (Part 3). flushSync forces an early flush of one batch; it doesn't change how scheduling works elsewhere.
  • Misconception: flushSync in 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 + useImperativeHandle are 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: act is 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, flushSync and dangerouslySetInnerHTML are 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. useImperativeHandle exists 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. dangerouslySetInnerHTML could have been innerHTML. The name is a permanent inline code review comment.

Try it yourself

  1. Build the Chat example twice: once with plain setMessages then scrollIntoView, once with flushSync. Log listRef.current.lastElementChild.textContent right after the update in both versions and observe the stale read without flushSync.
  2. Call flushSync inside a useEffect and inside a render body; read the warnings React gives you and connect them to the phase model.
  3. Extend VideoPlayer to expose seekTo(seconds). Then try to do "autoplay when a prop is true" through the handle, feel how awkward it is, and redo it with a playing prop. Which one synced better?
  4. Write the Counter test snippet, then delete the second act and run it under fake timers. Watch the assertion intermittently fail; restore act.

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.
  • findDOMNode is dead; use refs.
  • Meta-rule: hatches trade guarantees for control. Occasional use is engineering; frequent use is the architecture fighting the framework.

Next

Offscreen and Activity →