101: 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
refas a prop; - expose constrained imperative APIs with
useImperativeHandle; - render overlays with portals;
- understand
useLayoutEffectversususeEffect; - recognize when
useInsertionEffectis 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?
const timeoutRef = useRef(null);
Updating:
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
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:
function TextField({ label, ref, ...props }) {
return (
<label>
<span>{label}</span>
<input ref={ref} {...props} />
</label>
);
}
Parent:
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.
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:
<Modal open={open} />
over exposing:
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:
<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.
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.
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:
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
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
- Build a search input with an external Focus button.
- Implement a modal with
createPortaland accessible labelling. - Expose only
focus()andselect()throughuseImperativeHandle. - Build a tooltip measurement lab comparing
useEffectanduseLayoutEffect. - Refactor a component that stores visible UI state in refs into proper state.
Exit questions
- What is the difference between state and a ref?
- Why does changing
ref.currentnot rerender? - What changed about refs for function components in React 19?
- What does a portal change—and what does it not change?
- When is
useLayoutEffectjustified? - Why is
useInsertionEffectrarely application code?
Official references
- https://react.dev/reference/react/useRef
- https://react.dev/reference/react/useImperativeHandle
- https://react.dev/reference/react/useLayoutEffect
- https://react.dev/reference/react/useInsertionEffect
- https://react.dev/reference/react-dom/createPortal
Deep dive: refs are an escape hatch, not alternative state
A ref has stable object identity:
const ref = useRef(initialValue);
Across renders:
ref === previousRef
while:
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
| Need | State? | Ref? |
|---|---|---|
| render modal open/closed | yes | no |
| current timer ID | no | yes |
| input DOM node | no | yes |
| websocket instance | no | yes |
| visible counter | yes | no |
| previous drag coordinates | maybe no | yes |
| selected tab shown in UI | yes | no |
Reading/writing ref during render
Avoid using ref.current as ordinary render input:
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
const inputRef = useRef(null);
Before commit:
inputRef.current may be null
After commit:
inputRef.current = DOM input
After node removal:
React clears it
This is why you cannot reliably measure DOM in render.
Focus management
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
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:
<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:
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:
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:
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
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:
<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:
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:
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:
- render tooltip;
- measure height;
- choose above/below;
- 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
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
- Focus a field using React 19 ref-as-prop.
- Build an imperative handle that exposes only
focus. - Build a portal and demonstrate React event bubbling.
- Integrate a fake third-party widget with lifecycle and update Effects.
- Measure a tooltip with
useLayoutEffect. - 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;
useLayoutEffecttiming 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:
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:
open onOpenChange
rather than:
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:
dialog is open
The dialog primitive can say:
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:
- Can CSS Grid/Flex solve it?
- Can container queries solve it?
- Can Anchor Positioning solve it in supported browsers?
- Is approximate initial layout acceptable?
- 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
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
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:
DOM node → ref observer lifetime → Effect rendered visible state → state
Media control
const videoRef = useRef(null);
function play() {
videoRef.current?.play();
}
If React state should continuously synchronize playback:
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.
