FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·101·15 MIN READ

101: Refs, Portals, and DOM Escape Hatches

TOPICS COVERED: Refs, Portals, and DOM Escape Hatches

Learning objectives

You will learn to:

  • distinguish state from refs;
  • store mutable non-render data with useRef;
  • access DOM nodes safely;
  • use React 19 ref as a prop;
  • expose constrained imperative APIs with useImperativeHandle;
  • render overlays with portals;
  • understand useLayoutEffect versus useEffect;
  • recognize when useInsertionEffect is library-level infrastructure;
  • avoid using refs as hidden state.

The recurring question in this lesson is simple: does this value describe what React should render, or does it support some imperative work around that render? The answer usually tells you whether state or a ref is the right tool.

Ref mental model

State answers:

What should the component render?

A ref answers:

What mutable value must survive renders without causing another render?

That distinction is more useful than memorizing a list of ref APIs. State participates in React's rendering model. A ref gives you a stable container whose contents can change without scheduling a render.

jsx
const timeoutRef = useRef(null);

Updating:

jsx
timeoutRef.current = timeoutId;

does not rerender the component. If the screen needs to reflect the new value, the value is not merely ref data; it belongs in state or in some other render-driven input.

Good ref uses:

  • DOM nodes;
  • timers;
  • previous external handles;
  • third-party widget instances;
  • imperative integration state.

Poor ref uses:

  • current cart total displayed in JSX;
  • whether a modal should render;
  • selected tab;
  • a value whose change should update the screen.

Those belong in state. A ref can technically hold any JavaScript value, but its ability to avoid rendering is precisely why it is the wrong place for visible UI state.

DOM refs

When browser code needs the actual input element, a DOM ref provides the bridge without requiring a query through the document.

jsx
import { useRef } from 'react';

export default function SearchForm() {
  const inputRef = useRef(null);

  function focusSearch() {
    inputRef.current?.focus();
  }

  return (
    <>
      <input ref={inputRef} aria-label="Search tasks" />
      <button type="button" onClick={focusSearch}>
        Focus search
      </button>
    </>
  );
}

The ref starts with null, because there is no DOM node during the initial render. React assigns current when the element is committed, so the button's event handler can safely attempt to focus it. React sets current when the node is committed and clears it when removed.

Do not read or write ref.current during render for ordinary mutable logic. Render should remain a calculation. Reading a value that can be changed by an event makes the rendered result difficult to reason about, and writing during render creates side effects during a phase React may run more than once.

React 19: ref as a prop

Modern function components can receive ref as a prop. This removes the need for forwardRef in many new React 19 components.

jsx
function TextField({ label, ref, ...props }) {
  return (
    <label>
      <span>{label}</span>
      <input ref={ref} {...props} />
    </label>
  );
}

Parent:

jsx
function ProfileForm() {
  const nameRef = useRef(null);

  return (
    <>
      <TextField
        ref={nameRef}
        label="Name"
        name="name"
      />
      <button
        type="button"
        onClick={() => nameRef.current?.focus()}
      >
        Edit name
      </button>
    </>
  );
}

Older React code often uses forwardRef. You should recognize it, but new React 19 code can usually pass ref directly as a prop. This is an API change, not a change to the ref mental model: the ref still points to a committed DOM node or to the constrained handle a component exposes.

Exposing a constrained imperative API

Sometimes a parent genuinely needs to request an imperative browser action. Even then, avoid exposing the entire internal DOM node if the parent only needs one safe operation. useImperativeHandle lets the child define a small public contract.

jsx
import {
  useImperativeHandle,
  useRef,
} from 'react';

function SearchInput({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(
    ref,
    () => ({
      focus() {
        inputRef.current?.focus();
      },
      select() {
        inputRef.current?.select();
      },
    }),
    [],
  );

  return <input ref={inputRef} aria-label="Search" />;
}

The parent receives focus and select, not the component's implementation details. The child can later replace the input, wrap it, or change its internal structure without expanding the parent’s knowledge of the DOM.

This allows the component to preserve implementation details.

Use imperative handles sparingly. If behavior can be expressed declaratively through props, prefer props. Declarative inputs describe the desired result; the component then decides how to produce it.

For example, prefer:

jsx
<Modal open={open} />

over exposing:

js
modalRef.current.open()
modalRef.current.close()

unless imperative control is genuinely required. A focus request or text selection is a natural browser operation. Whether a modal is open is normally application state and should remain visible in the component API as data.

Ref callback

A callback ref can perform work when a node is attached or detached. React calls it with the node on attachment and with null when the node is detached.

jsx
<li
  ref={(node) => {
    if (node) {
      itemNodes.set(task.id, node);
    } else {
      itemNodes.delete(task.id);
    }
  }}
>

This is useful for dynamic collections, where a single object ref is not enough to locate every item.

Be careful to clean up external collections. Leaving removed nodes in a Map, for example, keeps stale references and can make keyboard navigation or other imperative logic act on elements that no longer exist.

Portals

A portal changes where DOM is placed, not where the component lives in the React tree. That distinction is the key to understanding both portals and their surprising behavior.

jsx
import { createPortal } from 'react-dom';

function Modal({ children }) {
  return createPortal(
    <div className="modal-layer">
      {children}
    </div>,
    document.body,
  );
}

Here, the modal layer is appended under document.body, which can avoid clipping from an ancestor's overflow or stacking context. The component is still logically rendered at the point where Modal appears in the React tree.

Context and React event propagation still follow the React tree.

This means a click inside a portal can bubble to a React ancestor even though the DOM node lives elsewhere. Do not infer React event behavior only from the browser's DOM ancestry.

Portals are useful for:

  • dialogs;
  • tooltips;
  • menus;
  • overlays;
  • toast layers.

A portal does not automatically make an accessible modal. You still need:

  • semantic dialog markup;
  • focus management;
  • labelled controls;
  • escape/cancel behavior;
  • background interaction rules where appropriate.

Prefer native <dialog> when it satisfies the product requirements. A portal solves placement; it does not provide the complete interaction and accessibility behavior of a modal system.

useEffect versus useLayoutEffect

The choice between these hooks is about timing, not preference. useEffect runs after the browser has had an opportunity to paint.

useLayoutEffect runs after DOM commit but before paint and can block painting.

Use useLayoutEffect only when the user must not see the intermediate layout. If an ordinary subscription, network request, or external synchronization can happen after paint, useEffect is the less disruptive choice.

Example: measure a tooltip before positioning it.

jsx
function Tooltip({ targetRect, children }) {
  const ref = useRef(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    const rect = ref.current.getBoundingClientRect();
    setHeight(rect.height);
  }, []);

  const top =
    targetRect.top - height < 0
      ? targetRect.bottom
      : targetRect.top - height;

  return (
    <div
      ref={ref}
      style={{
        position: 'fixed',
        left: targetRect.left,
        top,
      }}
    >
      {children}
    </div>
  );
}

The first render gives the tooltip a DOM node. The layout Effect measures its height, chooses whether it fits above the target, and updates the position before the browser paints the result. That avoids a visible jump, but the measurement and update block painting.

Do not replace every Effect with useLayoutEffect. Blocking paint harms responsiveness.

Server rendering caveat

Layout information does not exist on the server. There is no browser viewport or committed client DOM from which a server can obtain getBoundingClientRect() data.

A component relying on useLayoutEffect often needs to be:

  • client-only;
  • rendered after hydration;
  • redesigned so initial server HTML does not require measurement.

This becomes important in lesson 115. The general design question is whether the initial HTML can be useful without client-side geometry. CSS is often a better answer when it can express the layout.

useInsertionEffect

useInsertionEffect exists mainly for CSS-in-JS library authors who need style insertion before layout Effects. It provides an infrastructure-level timing point for a library that must make styles available before layout work runs.

Application code almost never needs it.

If you are using it to solve a normal component synchronization problem, the abstraction is probably wrong. Check whether the problem is really a normal Effect, a layout measurement, or a declarative styling issue before reaching for this hook.

Focus management example

When a validation failure occurs after submit, the user needs both an explanation and a sensible place to continue. A ref is appropriate for the focus operation, while state owns the visible error.

jsx
function TaskForm() {
  const titleRef = useRef(null);
  const [error, setError] = useState('');

  function submit(event) {
    event.preventDefault();

    const title = event.currentTarget.elements.title.value.trim();

    if (title.length < 3) {
      setError('Enter at least 3 characters.');
      requestAnimationFrame(() => {
        titleRef.current?.focus();
      });
      return;
    }
  }

  return (
    <form onSubmit={submit}>
      <label htmlFor="title">Title</label>
      <input id="title" name="title" ref={titleRef} />
      {error && <p role="alert">{error}</p>}
      <button>Save</button>
    </form>
  );
}

The error message is rendered through state, and the focus change is an imperative response to the failed submit. requestAnimationFrame lets the browser process the state-driven DOM update before the focus request runs.

Do not move focus on every render. Tie focus changes to a real interaction or UI transition. Otherwise unrelated renders can unexpectedly interrupt a user's current location.

Common mistakes

Using ref as render state

jsx
const countRef = useRef(0);

countRef.current += 1;

return <p>{countRef.current}</p>;

The UI will not update predictably because ref writes do not schedule renders. The value shown in the paragraph is only recalculated when something else causes a render, and mutating the ref during render adds another source of nondeterminism.

Imperative APIs everywhere

If parent components routinely control children through refs, re-evaluate the component API. A broad imperative surface usually means application state or declarative props have been pushed into DOM mechanics.

Measuring in render

DOM nodes are not reliably available during render. Measurement belongs after commit. Use an appropriate Effect, and use useLayoutEffect only when the measurement must affect the first painted position.

Forgetting portal event propagation

A portal's DOM location does not make it an isolated React subtree. Stop propagation only if the interaction design truly requires it.

Exercises

  1. Build a search input with an external Focus button.
  2. Implement a modal with createPortal and accessible labelling.
  3. Expose only focus() and select() through useImperativeHandle.
  4. Build a tooltip measurement lab comparing useEffect and useLayoutEffect.
  5. Refactor a component that stores visible UI state in refs into proper state.

Exit questions

  1. What is the difference between state and a ref?
  2. Why does changing ref.current not rerender?
  3. What changed about refs for function components in React 19?
  4. What does a portal change—and what does it not change?
  5. When is useLayoutEffect justified?
  6. Why is useInsertionEffect rarely application code?

Official references


Deep dive: refs are an escape hatch, not alternative state

A ref has stable object identity:

jsx
const ref = useRef(initialValue);

Across renders:

jsx
ref === previousRef

while:

jsx
ref.current

can change.

Changing .current does not schedule a render.

This makes refs ideal for information that is important to imperative code but not itself rendered state. The object remains the same handle from render to render, while its current slot can hold the latest timer, node, or external instance.

Ref use-case matrix

NeedState?Ref?
render modal open/closedyesno
current timer IDnoyes
input DOM nodenoyes
websocket instancenoyes
visible counteryesno
previous drag coordinatesmaybe noyes
selected tab shown in UIyesno

The matrix is a design aid, not a substitute for understanding the requirement. Previous drag coordinates may be ref data while a drag is in progress, for example, but a coordinate that must be displayed belongs in state. The deciding question is whether changing the value must produce a new render.

Reading/writing ref during render

Avoid using ref.current as ordinary render input:

jsx
function Counter() {
  const countRef = useRef(0);
  return <p>{countRef.current}</p>;
}

If another event changes the ref, React does not know it should rerender. The component can therefore display an old value until an unrelated update happens.

There are limited initialization patterns where ref creation during render is safe if result is stable/predictable, but treat render-time ref mutation as an advanced exception, not normal state design. In ordinary component logic, keep mutable work in event handlers or Effects and keep rendered data in state, props, or other React-managed inputs.

DOM lifecycle

jsx
const inputRef = useRef(null);

Before commit:

text
inputRef.current may be null

After commit:

text
inputRef.current = DOM input

After node removal:

text
React clears it

This is why you cannot reliably measure DOM in render. The render describes what should exist; only after commit does React have the corresponding browser node to hand to the ref.

Focus management

jsx
function SearchDialog({ open }) {
  const inputRef = useRef(null);

  useEffect(() => {
    if (open) {
      inputRef.current?.focus();
    }
  }, [open]);

  return open ? <input ref={inputRef} /> : null;
}

This focuses the input when open changes to true, rather than on every render. That is a good example of tying an imperative action to the state transition that requires it.

But accessible dialogs need more:

  • initial focus decision;
  • tab trapping/containment;
  • focus return;
  • Escape handling;
  • accessible name.

Prefer native <dialog> or battle-tested accessible primitives when possible. A focus call alone does not establish the dialog's complete keyboard, naming, background, and focus-restoration behavior.

Callback refs for collections

jsx
const itemMapRef = useRef(new Map());

function getItemRef(id) {
  return (node) => {
    const map = itemMapRef.current;

    if (node) {
      map.set(id, node);
    } else {
      map.delete(id);
    }
  };
}

Use:

jsx
<li ref={getItemRef(task.id)}>...</li>

This lets keyboard navigation/focus logic locate dynamic items. The callback updates the map when each item enters or leaves the committed DOM, so the map reflects the collection's actual nodes rather than a snapshot taken during render.

Remember callback identity/cleanup. More advanced patterns may memoize ref callbacks when necessary. If callback identity changes at an inconvenient time, React may detach the old callback and attach the new one; make sure the callback's cleanup behavior remains correct.

Ref as prop in React 19

Modern:

jsx
function TextInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

This reduces the need for forwardRef in new React 19 code.

You must still understand older code:

jsx
const TextInput = forwardRef(function TextInput(props, ref) {
  return <input ref={ref} {...props} />;
});

because ecosystem components may use it. When maintaining a mixed codebase, the wrapper is not evidence that the component behaves differently; it is the older way to pass the ref through a function component.

Imperative handles

Expose the smallest imperative contract:

jsx
function Editor({ ref }) {
  const inputRef = useRef(null);

  useImperativeHandle(
    ref,
    () => ({
      focusTitle() {
        inputRef.current?.focus();
      },
      clearSelection() {
        const input = inputRef.current;
        if (input) {
          input.setSelectionRange(0, 0);
        }
      },
    }),
    [],
  );

  return <input ref={inputRef} />;
}

This protects internal DOM structure. The caller can request the two operations the component intentionally supports without depending on the particular element used to implement them.

If the parent needs 20 imperative methods, the component API likely needs redesign. A narrow handle should represent an exceptional imperative boundary, not become a second, hidden state-management protocol.

Portals and event semantics

jsx
createPortal(
  <ModalContent />,
  document.body,
)

moves DOM placement.

React context remains from logical parent.

React event bubbling follows React tree.

This can surprise developers who inspect only DOM ancestry. The browser sees a button under body, while React still sees the button as a descendant of the component that called createPortal.

Test:

jsx
<div onClick={() => console.log('parent')}>
  {createPortal(
    <button>Portal button</button>,
    document.body,
  )}
</div>

Click can reach React parent handler. If that is not the desired interaction, decide whether propagation should be stopped at the portal boundary; do not assume moving the DOM node has already done so.

Portal layering

Portals often solve clipping:

css
overflow: hidden

and stacking/layer issues by rendering near body. They change the DOM context in which the overlay participates, which can help it escape an ancestor's clipping boundary.

But portal does not automatically solve:

  • z-index;
  • modal semantics;
  • focus;
  • scroll lock;
  • inert background;
  • nested overlay coordination.

A dedicated overlay system may own these concerns. Treat placement as one part of overlay behavior, not as a complete modal implementation.

useLayoutEffect timing

Timeline:

text
render
commit DOM
useLayoutEffect
browser paint
useEffect

A layout Effect can measure and synchronously update before paint, preventing visible jump.

But it blocks paint, so use sparingly. The cost is especially relevant when the work is expensive or when many components perform layout work in the same commit.

Example tooltip:

  1. render tooltip;
  2. measure height;
  3. choose above/below;
  4. update before paint.

For normal subscriptions/network work, useEffect is better. Those operations do not usually need to alter the geometry of the first painted frame.

Hydration caveat

Server has no layout.

A server-rendered component that fundamentally requires browser measurement may:

  • render a neutral initial layout;
  • become client-only;
  • defer enhanced behavior;
  • use CSS instead of JavaScript measurement where possible.

Avoid useLayoutEffect as a default styling mechanism. During hydration, the server's HTML and the browser's initial layout need a strategy that does not depend on measurements unavailable until the client has committed the DOM.

Third-party widget integration

jsx
function Chart({ data }) {
  const containerRef = useRef(null);
  const chartRef = useRef(null);

  useEffect(() => {
    chartRef.current = new ChartLibrary(containerRef.current);

    return () => {
      chartRef.current?.destroy();
      chartRef.current = null;
    };
  }, []);

  useEffect(() => {
    chartRef.current?.setData(data);
  }, [data]);

  return <div ref={containerRef} />;
}

One Effect owns widget lifetime. It creates the widget after the container exists and destroys it when the component is removed.

Another owns data synchronization. It updates the existing instance as data changes.

This is easier to reason about than recreating the widget every time data changes. The refs hold the DOM container and the external instance, while the Effects define when those imperative resources are created, updated, and released.

Failure clinic

Ref used for visible state

UI does not update. Look for a write to .current that is expected to change JSX; replace that ownership with state when the value is part of the rendered result.

Missing cleanup for widget

Memory/event handlers leak. Check the Effect that creates the widget and make its cleanup destroy the instance and release the ref.

Measuring too early

ref.current null or layout not committed. Move the measurement after commit and confirm that the component is running in the browser when server rendering is involved.

Portal assumed accessible

Overlay renders visually but keyboard focus escapes behind it. Audit semantics, focus containment, Escape handling, focus restoration, and background interaction rather than treating the portal itself as the accessibility solution.

Deep-dive exercises

  1. Focus a field using React 19 ref-as-prop.
  2. Build an imperative handle that exposes only focus.
  3. Build a portal and demonstrate React event bubbling.
  4. Integrate a fake third-party widget with lifecycle and update Effects.
  5. Measure a tooltip with useLayoutEffect.
  6. Audit when CSS could replace JavaScript measurement.

Mastery check

Explain:

  • why refs do not rerender;
  • DOM ref lifecycle;
  • ref-as-prop change in React 19;
  • why imperative handles should be narrow;
  • portal DOM versus React tree;
  • useLayoutEffect timing and cost.

Production case study: accessible modal focus without turning the app imperative

A modal is a good example of where refs help but should remain contained. The feature needs declarative state for whether the dialog exists, and the dialog implementation may still need imperative browser operations for focus and native dialog behavior.

Parent stays declarative:

jsx
function TaskPage() {
  const [deleteOpen, setDeleteOpen] = useState(false);

  return (
    <>
      <button onClick={() => setDeleteOpen(true)}>
        Delete task
      </button>

      <DeleteDialog
        open={deleteOpen}
        onOpenChange={setDeleteOpen}
      />
    </>
  );
}

The dialog implementation may use refs internally for focus restoration or a native <dialog> element, but the parent API remains:

text
open
onOpenChange

rather than:

jsx
dialogRef.current.show()
dialogRef.current.hide()
dialogRef.current.setTask(...)

The page owns the business decision, while the dialog owns the browser mechanics. That boundary keeps feature behavior testable and prevents callers from depending on the dialog's internal DOM.

Ref ownership rule

Use imperative APIs at the lowest layer that actually needs the imperative browser behavior.

The business page should say:

text
dialog is open

The dialog primitive can say:

text
focus this element
call showModal
restore trigger focus

This separation prevents DOM mechanics leaking into feature architecture. It also gives the low-level primitive one place to handle browser timing and accessibility details.

Measurement alternative checklist

Before using useLayoutEffect + measurement, ask:

  1. Can CSS Grid/Flex solve it?
  2. Can container queries solve it?
  3. Can Anchor Positioning solve it in supported browsers?
  4. Is approximate initial layout acceptable?
  5. Is measurement required before paint?

Modern CSS can eliminate many historical React measurement Effects. If CSS can establish the relationship, it avoids JavaScript timing, hydration complications, and paint-blocking work. When it cannot, the final question clarifies whether useLayoutEffect is actually justified.


Additional depth: scrolling, media, observers, and imperative browser APIs

Refs are also common when integrating browser APIs that require actual DOM nodes. The same ownership model still applies: the ref identifies the external object, an Effect manages synchronization or lifetime, and state stores anything the UI must render.

Scroll a selected item into view

jsx
function TaskRow({ selected, task }) {
  const rowRef = useRef(null);

  useEffect(() => {
    if (selected) {
      rowRef.current?.scrollIntoView({
        block: 'nearest',
      });
    }
  }, [selected]);

  return (
    <li ref={rowRef}>
      {task.title}
    </li>
  );
}

Before adding this behavior, ask whether automatic scrolling will surprise keyboard/screen-reader users. Imperative behavior should follow clear interaction intent. A selected item may be selected for reasons that do not mean the user wants the viewport moved, so the product behavior should make that choice explicit.

IntersectionObserver

jsx
function useVisible(ref) {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const node = ref.current;

    if (!node) return;

    const observer = new IntersectionObserver(([entry]) => {
      setVisible(entry.isIntersecting);
    });

    observer.observe(node);

    return () => {
      observer.disconnect();
    };
  }, [ref]);

  return visible;
}

The DOM node arrives through the ref, the Effect owns the observer's lifetime, and the observer's result becomes state because visibility is rendered data.

For a reusable external-store-style observer, you may design a stronger abstraction. The key lesson is ownership:

text
DOM node → ref
observer lifetime → Effect
rendered visible state → state

This separation makes cleanup and rerender behavior explicit instead of hiding them in one mutable object.

Media control

jsx
const videoRef = useRef(null);

function play() {
  videoRef.current?.play();
}

Calling play() is an imperative browser operation on the media element. The ref gives the handler access to that element without making the element itself render state.

If React state should continuously synchronize playback:

jsx
useEffect(() => {
  if (playing) {
    videoRef.current?.play();
  } else {
    videoRef.current?.pause();
  }
}, [playing]);

This is a textbook Effect: React state synchronizes an external media system. The state remains the source of the desired playback mode, while the Effect translates it into calls to the browser API.

Escape-hatch rule

When using a ref, write down why declarative state/props cannot express the requirement. If you cannot answer, ref usage may be hiding state ownership rather than solving an imperative integration.

Reader page: /react/lesson/101/refs-portals-and-dom-escape-hatches