Module: React and Ecosystem
React and Ecosystem·101·8 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.

Ref mental model

State answers:

What should the component render?

A ref answers:

What mutable value must survive renders without causing another render?

jsx
const timeoutRef = useRef(null);

Updating:

jsx
timeoutRef.current = timeoutId;

does not rerender the component.

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.

DOM refs

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>
    </>
  );
}

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.

React 19: ref as a prop

Modern function components can receive ref as a prop:

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.

Exposing a constrained imperative API

Do not expose an entire internal DOM node if the parent only needs one safe operation.

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" />;
}

This allows the component to preserve implementation details.

Use imperative handles sparingly. If behavior can be expressed declaratively through props, prefer props.

For example, prefer:

jsx
<Modal open={open} />

over exposing:

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

unless imperative control is genuinely required.

Ref callback

A callback ref can perform work when a node is attached or detached:

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

This is useful for dynamic collections.

Be careful to clean up external collections.

Portals

A portal changes where DOM is placed, not where the component lives in the React tree.

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

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

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.

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.

useEffect versus useLayoutEffect

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.

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>
  );
}

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

Server rendering caveat

Layout information does not exist on the server.

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.

useInsertionEffect

useInsertionEffect exists mainly for CSS-in-JS library authors who need style insertion before layout Effects.

Application code almost never needs it.

If you are using it to solve a normal component synchronization problem, the abstraction is probably wrong.

Focus management example

When a validation failure occurs after submit:

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>
  );
}

Do not move focus on every render. Tie focus changes to a real interaction or UI transition.

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.

Imperative APIs everywhere

If parent components routinely control children through refs, re-evaluate the component API.

Measuring in render

DOM nodes are not reliably available during render. Measurement belongs after commit.

Forgetting portal event propagation

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.

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

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.

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.

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.

Focus management

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

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

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

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.

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.

Remember callback identity/cleanup. More advanced patterns may memoize ref callbacks when necessary.

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.

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.

If the parent needs 20 imperative methods, the component API likely needs redesign.

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.

Test:

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

Click can reach React parent handler.

Portal layering

Portals often solve clipping:

css
overflow: hidden

and stacking/layer issues by rendering near body.

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.

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.

Example tooltip:

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

For normal subscriptions/network work, useEffect is better.

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.

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.

Another owns data synchronization.

This is easier to reason about than recreating the widget every time data changes.

Failure clinic

Ref used for visible state

UI does not update.

Missing cleanup for widget

Memory/event handlers leak.

Measuring too early

ref.current null or layout not committed.

Portal assumed accessible

Overlay renders visually but keyboard focus escapes behind it.

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.

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(...)

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.

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.


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

Refs are also common when integrating browser APIs that require actual DOM nodes.

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.

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;
}

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

Media control

jsx
const videoRef = useRef(null);

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

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.

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.