Refs, Portals & Escape Hatches
When declarative is not enough
Section titled “When declarative is not enough”Ninety-nine percent of React is declarative: change state, describe the UI, let React sync the DOM. But a few real problems need you to step outside that model — reach into the DOM, force timing, or render somewhere the component tree does not naturally allow. React provides deliberate escape hatches for these. The skill is knowing they exist and using them sparingly.
Portals: render outside the parent DOM
Section titled “Portals: render outside the parent DOM”A portal renders children into a different part of the DOM tree, while keeping them in the React tree where you wrote them. This is the standard solution for modals, tooltips, and dropdowns — UI that must escape a parent’s overflow: hidden or z-index stacking context.
import { createPortal } from "react-dom";
function Modal({ children }) { // Rendered into document.body, but still a React child of wherever <Modal/> is used. return createPortal( <div className="overlay">{children}</div>, document.body, );}flowchart TB react["React tree: App → Page → Modal → content"] dom["DOM tree: body → app-root ... body → overlay (portal target)"] react -->|logically a child of Page| dom note["Events still bubble through the React tree, not the DOM location"]
The key subtlety: a portal changes where the DOM node lives, not where it sits in the React tree. Context still flows in, and events still bubble up through the React parent — so a click inside a portaled modal still reaches handlers on its logical React ancestors.
Proof: context and event bubbling both cross the portal boundary
Section titled “Proof: context and event bubbling both cross the portal boundary”import { createContext, useContext, type ReactNode } from 'react';import { createPortal } from 'react-dom';import { render, screen, fireEvent } from '@testing-library/react';
const ThemeContext = createContext('light');
function PortalModal({ children }: { children: ReactNode }) { // Rendered into document.body — a different DOM subtree entirely. return createPortal(children, document.body);}
function ThemedLabel() { // Reads context from the LOGICAL (React tree) ancestor, not the DOM ancestor. const theme = useContext(ThemeContext); return <p data-testid="label">theme: {theme}</p>;}
test('a portal still receives context from its React-tree ancestor, not its DOM ancestor', () => { render( <ThemeContext.Provider value="dark"> <PortalModal> <ThemedLabel /> </PortalModal> </ThemeContext.Provider>, );
// ThemedLabel is physically a direct child of document.body, outside the // render container, yet it still reads "dark" from the Provider that // wraps it in the React tree. expect(screen.getByTestId('label').textContent).toBe('theme: dark');});
test('a click inside a portal bubbles through the React tree, not the DOM tree', () => { const onClick = vi.fn();
function App() { return ( // onClick lives on a React ancestor that is NOT a DOM ancestor of the // portaled button (the portal renders straight into document.body). <div onClick={onClick}> <PortalModal> <button>click me</button> </PortalModal> </div> ); }
render(<App />); fireEvent.click(screen.getByText('click me'));
// Proven: the click reaches the React-ancestor's handler even though the // button's actual DOM parent is document.body, not <div>. expect(onClick).toHaveBeenCalledTimes(1);});✓ a portal still receives context from its React-tree ancestor, not its DOM ancestor✓ a click inside a portal bubbles through the React tree, not the DOM tree
Test Files 1 passed (1) Tests 2 passed (2)Per react.dev: “Events from portals propagate according to the React tree rather than the DOM tree. For example, if you click inside a portal, and the portal is wrapped in <div onClick>, that onClick handler will fire.” If that surprises a consumer of your modal (say, a click handler on an outer wrapper that assumes “nothing outside my DOM subtree can trigger this”), the fix is to stop propagation from inside the portal’s content, not to avoid portals.
Imperative refs: calling into a child or the DOM
Section titled “Imperative refs: calling into a child or the DOM”Most of the time data flows down through props. But sometimes a parent needs to imperatively trigger something on a child or DOM node — focus an input, play a video, scroll to an element, measure a size. A ref gives you that handle.
function SearchForm() { const inputRef = useRef(null); return ( <> <input ref={inputRef} /> <button onClick={() => inputRef.current.focus()}>Focus</button> </> );}For anything that reads layout — measuring a node’s size or position, then immediately acting on it (scrolling, positioning a tooltip) — do that measuring in useLayoutEffect, not useEffect. useLayoutEffect runs synchronously after React has updated the DOM but before the browser paints, so a measurement taken there reflects the just-committed layout with no visible flash; useEffect runs after paint, so a measure-then-reposition inside it can flicker.
function AutoSizedBox({ text }) { const boxRef = useRef(null); const [tooTall, setTooTall] = useState(false);
useLayoutEffect(() => { // Runs after the DOM commit, before paint — safe to measure real layout here. setTooTall(boxRef.current.scrollHeight > 200); }, [text]);
return <div ref={boxRef}>{text}</div>;}To expose a custom imperative API from a component (rather than the raw DOM node), use useImperativeHandle to define exactly which methods a parent’s ref can call — see useRef & the DOM for the full hook and its React 19 ref-callback-cleanup behavior. Prefer props/state for anything that can be declarative; reach for an imperative ref only for genuinely imperative actions (focus, scroll, media, measurement).
flushSync: opt out of batching
Section titled “flushSync: opt out of batching”React batches state updates and applies the DOM changes asynchronously for performance. Occasionally you need the DOM updated synchronously — for example, to read the new layout immediately (measure a just-added element, then scroll to it). flushSync forces a state update and its DOM commit to happen right away, before the next line of your code runs.
import { flushSync } from "react-dom";
flushSync(() => setItems([...items, newItem])); // DOM is updated by the time this returnslistRef.current.scrollTop = listRef.current.scrollHeight; // now safe to read/scrollProof: the DOM is genuinely updated before the next line, only with flushSync
Section titled “Proof: the DOM is genuinely updated before the next line, only with flushSync”import { useState, useRef } from 'react';import { flushSync } from 'react-dom';import { render, screen, act } from '@testing-library/react';
function List({ onAfterUpdate }: { onAfterUpdate: (label: string, count: number) => void }) { const [items, setItems] = useState(['a']); const listRef = useRef<HTMLUListElement>(null);
function addNoFlush() { setItems((prev) => [...prev, 'b']); // 🔴 React has NOT committed the DOM yet — this reads the OLD count. onAfterUpdate('noFlush', listRef.current!.children.length); }
function addWithFlush() { flushSync(() => { setItems((prev) => [...prev, 'b']); }); // ✅ flushSync forced the commit — the DOM already has the new item. onAfterUpdate('withFlush', listRef.current!.children.length); }
return ( <div> <button onClick={addNoFlush}>no-flush</button> <button onClick={addWithFlush}>with-flush</button> <ul ref={listRef}> {items.map((it, i) => ( <li key={i}>{it}</li> ))} </ul> </div> );}
test('without flushSync, the DOM has not been updated by the line right after setState', () => { const results: Array<[string, number]> = []; render(<List onAfterUpdate={(label, count) => results.push([label, count])} />);
act(() => { screen.getByText('no-flush').click(); });
expect(results).toEqual([['noFlush', 1]]); // still the OLD count — commit hasn't happened yet});
test('flushSync forces the DOM to update before the next line runs', () => { const results: Array<[string, number]> = []; render(<List onAfterUpdate={(label, count) => results.push([label, count])} />);
act(() => { screen.getByText('with-flush').click(); });
expect(results).toEqual([['withFlush', 2]]); // the NEW count — already committed});✓ without flushSync, the DOM has not been updated by the line right after setState✓ flushSync forces the DOM to update before the next line runs
Test Files 1 passed (1) Tests 2 passed (2)flushSync defeats batching and hurts performance, so use it only when you truly must read or act on the DOM before the browser paints — react.dev is explicit that “using flushSync is uncommon, and using it often can significantly hurt the performance of your app.”
Escape hatches checklist
Section titled “Escape hatches checklist”Before reaching for any of the tools on this page, work through this in order:
- Can this stay declarative? If the answer is “describe the desired state and let React render it,” do that instead — every escape hatch below is a deliberate opt-out of React’s normal guarantees.
- Portal — only when you need a different DOM location (stacking context, overflow) while staying in the same logical tree. Not a general-purpose “render somewhere else” tool.
- Ref (+
useImperativeHandle) — only for a genuinely imperative action (focus, scroll, play, measure) that has no natural state representation. If you’re storing UI state in a ref “to avoid a re-render,” that’s usually a sign the value belongs in state. flushSync— only when a third-party API or the browser itself needs the DOM synchronously updated before your next line runs (print dialogs, scroll-to-just-added-item). Never reach for it just to “make an update happen sooner” inside pure React code.- Measure with
useLayoutEffect, notuseEffect, whenever the very next thing you do is act on layout you just read — the timing difference is the whole point.
Mental Model
Section titled “Mental Model”Every tool on this page answers the same question differently: “React already knows how to keep the DOM in sync with state — where does that break down for me?” A portal breaks the assumption that where you write JSX is where the DOM node lives. A ref breaks the assumption that all interaction flows through props and state. flushSync breaks the assumption that you never need to know exactly when a commit happened. None of these change what React is — they are named, deliberate exceptions to specific guarantees, used only where the declarative model genuinely cannot express what you need.
Trade-offs
Section titled “Trade-offs”| Escape hatch | What it buys you | What it costs |
|---|---|---|
createPortal | Renders past a clipping/stacking-context parent while keeping context + event bubbling | Two-file mental model (DOM location vs. React position); stopPropagation needed if bubbling surprises a consumer |
Ref / useImperativeHandle | Direct imperative control (focus, scroll, measure) with no natural declarative equivalent | Bypasses React’s data flow — harder to reason about from props/state alone |
flushSync | Guarantees the DOM is updated by the very next line | Defeats batching for that update — real performance cost if overused |
useLayoutEffect for measuring | No paint flicker from measure-then-reposition | Runs synchronously before paint — a slow layout effect blocks the browser from painting |
Common Misconceptions
Section titled “Common Misconceptions”- “A portal moves a component out of the React tree.” Proven above: context still flows in, and click events still bubble through the React ancestors — only the DOM location changed.
- “Refs are for reading render output.” A ref you read during render can be stale or null; refs are for imperative actions after commit (or reading committed layout in
useLayoutEffect), not a substitute for props/state during render. - “
flushSyncmakes React faster.” The opposite — proven above, it forces a synchronous commit that bypasses batching, which is normally a performance win you’re deliberately giving up for one specific read. - “
useEffectanduseLayoutEffectare interchangeable for measuring layout.” They fire at different times relative to paint; a measure-then-reposition insideuseEffectcan visibly flicker where the same code inuseLayoutEffectcannot, becauseuseLayoutEffectruns before the browser paints.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated code that uses refs, portals, or flushSync, check for:
- Is
flushSynccalled inside auseEffect(or any place other than direct response to a browser/DOM API that specifically requires a synchronous read)? That’s almost always unnecessary and a performance smell, not a fix. - Does a portal’s content assume it is isolated from outer click handlers because it “moved to
document.body”? Events still bubble through the React tree — that assumption needsstopPropagation, not surprise later. - Is a measurement (size, scroll position) taken in
useEffectand then used to immediately reposition something? That belongs inuseLayoutEffectto avoid a visible flicker. - Is
useImperativeHandleexposing the entire DOM node instead of a narrow, specific set of methods (focus,scrollIntoView)? That defeats the point of using it over a plain ref. - Could whatever the ref/portal/
flushSyncis doing be expressed as ordinary state and a declarative render instead? That should always be considered first.
🐛 Spot the bug
function ChatWindow({ messages }) {const listRef = useRef(null);
useEffect(() => { flushSync(() => { listRef.current.scrollTop = listRef.current.scrollHeight; });}, [messages]);
return ( <ul ref={listRef}> {messages.map((m) => <li key={m.id}>{m.text}</li>)} </ul>);}Show the answer
flushSync is meant to wrap a state update so that its DOM commit happens synchronously before the next line reads the DOM — here it wraps a plain DOM mutation (listRef.current.scrollTop = ...) with no state update inside it at all, so it does nothing useful; flushSync’s entire purpose (forcing a pending commit to happen early) does not apply when there is no pending commit to force. Worse, this scroll-to-bottom logic runs in useEffect, which fires after the browser has already painted — on a long message list, the user can see the old scroll position for a frame before it jumps, exactly the flicker useLayoutEffect exists to prevent. The fix: drop flushSync entirely (there’s no state update to flush) and move the scroll logic into useLayoutEffect, so it runs after the DOM commit but before paint.