ReactDOM.createPortal(children, domNode) renders children into a different
DOM node than the component's parent, while keeping them inside the React
component tree. The portal children still receive context, state, and events
from their React parent — only the DOM placement changes.
import { createPortal } from 'react-dom'
function Modal({ children, onClose }) {
return createPortal(
// JSX rendered here...
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
// ...but mounted into this DOM node, outside the app root
document.getElementById('modal-root')
)
}
Use portals when you need a component to escape the CSS stacking context of
its parent — modals, tooltips, dropdown menus, toasts, and popovers all commonly
need to render at the <body> level to avoid overflow: hidden or z-index
clipping from ancestor elements.
Rule of thumb: Reach for createPortal any time a UI element needs to visually
float above the rest of the page, regardless of where it lives in the component tree.
No — portals are a DOM-only escape hatch. React's virtual component tree is unchanged, so everything that flows through the React tree — context values, state, refs, and synthetic events — still works as if the portal were rendered inline.
function App() {
const [theme] = useContext(ThemeContext) // 'dark'
return (
<ThemeContext.Provider value="dark">
<Modal>
{/* ThemeContext reads 'dark' even though Modal
is mounted under document.body in the DOM */}
<ThemedButton />
</Modal>
</ThemeContext.Provider>
)
}
This means a portal component can consume any context provided by its React ancestors, even if those ancestors are DOM siblings or even DOM ancestors of the portal's mount point.
Rule of thumb: Think of portals as a teleport for DOM placement only — the React component lineage is unaffected, so all React features keep working normally.
Synthetic events inside a portal bubble through the React component tree, not
the DOM tree. A click inside a modal portal bubbles up to the React ancestor
that rendered the portal, even though the modal's DOM node is a sibling of
#root in the actual DOM.
function Page() {
// This handler fires when the button inside Modal is clicked,
// because the portal's React parent is Page — even though
// the modal DOM is outside #root entirely.
const handleClick = () => console.log('caught in Page')
return (
<div onClick={handleClick}>
<Modal>
<button>Click me</button> {/* bubbles to Page via React tree */}
</Modal>
</div>
)
}
The gotcha: if you add a document.addEventListener('click', ...) outside React
and expect portal clicks to bubble through the DOM, you'll see them — but a React
onClick placed on a DOM ancestor of #root will also fire because the native
event does travel through the real DOM too. The React synthetic event system
is what bubbles through the React tree.
Rule of thumb: Always think of event bubbling in terms of the React component tree, not the rendered DOM tree — portals don't change the React ancestry.
CSS z-index is scoped to a stacking context. An ancestor with
transform, opacity < 1, position: relative/absolute and a z-index, or
overflow: hidden creates a new stacking context that caps the z-index of all
its descendants. A modal with z-index: 9999 is still trapped below an ancestor
with z-index: 1 if that ancestor forms its own stacking context.
// Without portal — modal trapped inside stacking context of .card
// <div class="card" style="transform: translateZ(0); z-index: 1">
// <Modal /> ← z-index: 9999 but still below sibling stacking contexts
// </div>
// With portal — modal escapes to body, outside all stacking contexts
function Modal({ children }) {
return createPortal(
<div className="modal" style={{ zIndex: 9999 }}>
{children}
</div>,
document.body // top-level stacking context
)
}
Portaling to document.body (or a dedicated #modal-root sibling of #root)
removes the modal from all ancestor stacking contexts and lets it freely float
above every other element.
Rule of thumb: If a modal or tooltip is clipped or covered despite a high
z-index, the component tree has a stacking context trap — use createPortal
to escape it.
A modal portal must manage focus trap, aria attributes, and keyboard dismissal explicitly, because rendering outside the normal DOM flow doesn't automatically handle any of these.
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
function Modal({ isOpen, onClose, children }) {
const dialogRef = useRef(null)
useEffect(() => {
if (!isOpen) return
// Move focus into the modal when it opens
dialogRef.current?.focus()
// Close on Escape
const onKey = (e) => e.key === 'Escape' && onClose()
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
role="dialog"
aria-modal="true"
tabIndex={-1} // makes div focusable
ref={dialogRef}
className="modal"
>
{children}
</div>,
document.body
)
}
Key requirements: role="dialog", aria-modal="true" (tells screen readers
to ignore background content), focus moved into the dialog on open, and Escape
key closes it. Libraries like focus-trap-react automate the focus-trap cycle.
Rule of thumb: A portal gives you DOM freedom, but accessibility is still
your responsibility — always add role="dialog", aria-modal, and focus management.
React automatically removes portal content from the DOM when the component
that rendered the portal unmounts. You do not need to manually call
ReactDOM.unmountComponentAtNode for standard portals.
function App() {
const [showModal, setShowModal] = useState(false)
return (
<>
<button onClick={() => setShowModal(true)}>Open</button>
{/* When showModal becomes false, React unmounts Modal and
removes its portal DOM from document.body automatically */}
{showModal && (
<Modal onClose={() => setShowModal(false)}>
<p>Content</p>
</Modal>
)}
</>
)
}
If you dynamically created the mount node yourself (e.g. with
document.createElement('div') and appendChild), you are responsible for
removing that container element in a cleanup function or useEffect return.
The portal content inside it is still cleaned by React; the container node
itself is your concern.
Rule of thumb: Let React manage unmounting; only clean up manually if you created the portal's host DOM element dynamically outside of React.
Jest + React Testing Library (RTL) works with portals out of the box because
RTL renders into document.body by default and createPortal also targets
document.body — so portal content is in document.body and queryable normally.
// Modal.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Modal from './Modal'
test('renders portal content in document.body', () => {
render(<Modal isOpen onClose={() => {}}>Hello portal</Modal>)
// Even though content is in a portal, RTL queries search document.body
expect(screen.getByText('Hello portal')).toBeInTheDocument()
})
test('calls onClose on Escape key', async () => {
const onClose = jest.fn()
render(<Modal isOpen onClose={onClose}>Content</Modal>)
await userEvent.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
If your portal targets a custom node (e.g. document.getElementById('modal-root')),
add <div id="modal-root" /> to document.body in a beforeEach and clean it
up in afterEach.
Rule of thumb: RTL's document-level query approach means portals require no special setup — only custom mount targets need a manual DOM fixture.
useRef(initialValue) returns a plain mutable object { current: initialValue }
that persists for the entire lifetime of the component. Mutating .current does
not trigger a re-render.
// Use case 1: DOM access
function TextInput() {
const inputRef = useRef(null)
const focus = () => inputRef.current.focus() // imperative DOM call
return <input ref={inputRef} />
}
// Use case 2: mutable container (timer id, previous value, etc.)
function Interval() {
const timerId = useRef(null)
const start = () => { timerId.current = setInterval(tick, 1000) }
const stop = () => clearInterval(timerId.current)
return <><button onClick={start}>Start</button><button onClick={stop}>Stop</button></>
}
The two distinct jobs: (1) hold a reference to a DOM element so you can call imperative DOM APIs (focus, scroll, measure); (2) store any mutable value that must survive re-renders without causing them — timers, intervals, previous state, animation frame IDs, WebSocket instances.
Rule of thumb: If you need to read or write a value across renders without
triggering one, useRef is the right tool — it's essentially instance state for
function components.
The fundamental difference: state changes schedule a re-render; ref mutations do not. Both persist across renders, but only state drives the UI.
function Counter() {
const [count, setCount] = useState(0) // changing → re-render
const renderCount = useRef(0) // changing → no re-render
useEffect(() => {
renderCount.current += 1 // track silently, no render loop
})
return (
<div>
<p>Count: {count}</p> {/* must be state */}
<p>Renders: {renderCount.current}</p> {/* stale between renders, but OK here */}
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
)
}
Ref mutations are also synchronous — ref.current updates immediately with
no batching. State updates may be batched and applied asynchronously.
Choose state when the value needs to appear in the rendered output or drive conditional logic inside JSX. Choose ref when the value is used by event handlers / effects only and shouldn't re-render the component when it changes.
Rule of thumb: If a value needs to be visible in the UI, it must be state; if it's behind-the-scenes bookkeeping, a ref keeps the render cycle clean.
A callback ref is a function passed to the ref prop instead of a ref
object. React calls it with the DOM node when the component mounts, and with
null when it unmounts.
function MeasuredBox() {
const [height, setHeight] = useState(0)
// Called once with the DOM node on mount, null on unmount
const measuredRef = useCallback((node) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height)
}
}, []) // stable reference — no re-registration on every render
return (
<div ref={measuredRef}>
Height is {height}px
</div>
)
}
useRef gives you the node after render but doesn't notify you when the node
changes. A callback ref is superior when you need to react to the node being
attached or detached — measuring layout after conditional rendering, attaching
third-party libraries imperatively, or observing dynamic child nodes.
Rule of thumb: Use useRef for stable, always-mounted nodes; use a callback
ref when you need to run code the moment the element appears or disappears in the DOM.
By default, the ref prop on a custom component is not forwarded to the
underlying DOM element — React reserves ref and doesn't pass it through props.
React.forwardRef opts a component into forwarding the ref to a child element.
// Without forwardRef: ref on <FancyInput> points to nothing useful
// With forwardRef: ref reaches the real <input>
const FancyInput = React.forwardRef((props, ref) => (
<input
{...props}
ref={ref} // forwarded ref attached to DOM input
className="fancy"
/>
))
// Parent can now focus the input imperatively
function Form() {
const inputRef = useRef(null)
return (
<>
<FancyInput ref={inputRef} placeholder="Type here" />
<button onClick={() => inputRef.current.focus()}>Focus</button>
</>
)
}
Ref forwarding is common in design-system / component-library components (buttons, inputs, modals) that wrap a native element but need to expose the DOM node to consumers for accessibility or focus management.
Rule of thumb: Any reusable component that wraps a DOM element should use
forwardRef so library consumers retain imperative DOM access.
useImperativeHandle(ref, createHandle, deps) lets a child component control
what the parent's ref exposes — instead of exposing the raw DOM node, it
exposes a custom object with only the methods the parent should call.
const VideoPlayer = React.forwardRef((props, ref) => {
const videoRef = useRef(null)
// Parent only gets play/pause — not direct DOM access
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
}), [])
return <video ref={videoRef} src={props.src} />
})
function App() {
const playerRef = useRef(null)
return (
<>
<VideoPlayer ref={playerRef} src="/clip.mp4" />
<button onClick={() => playerRef.current.play()}>Play</button>
</>
)
}
This is an encapsulation tool: it prevents parent components from reaching into child internals and calling arbitrary DOM methods, keeping the API surface intentionally minimal.
Rule of thumb: Prefer useImperativeHandle over raw forwardRef whenever
you need to expose imperative behavior from a child component — it enforces a
clean, intentional API boundary.
Refs persist across renders without resetting, so they're perfect for storing
the value from the last render before state/props update. The pattern is a
one-liner useEffect that writes .current after every render.
function usePrevious(value) {
const ref = useRef(undefined)
useEffect(() => {
// Runs after render — so during the current render, ref.current
// still holds the value from the PREVIOUS render
ref.current = value
})
return ref.current // previous render's value
}
function PriceDisplay({ price }) {
const prevPrice = usePrevious(price)
const direction = price > prevPrice ? '▲' : '▼'
return <span>{price} {prevPrice !== undefined && direction}</span>
}
This works because useEffect runs after the render is committed to the DOM.
During the render that reads ref.current, the effect from the previous render
has already written the old value in, but the new value hasn't been written yet.
Rule of thumb: Store previous values in a ref, not state — reading the previous value shouldn't cause an extra re-render.
Storing a timer ID in state would trigger a re-render every time you start or clear a timer, which is wasteful and can cause cascading effects. A ref stores it silently.
function Debounced({ onSearch }) {
const timerRef = useRef(null)
const handleChange = (e) => {
// Cancel previous timer without re-rendering
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
onSearch(e.target.value)
}, 300)
}
// Clean up on unmount to prevent calling onSearch after component is gone
useEffect(() => {
return () => clearTimeout(timerRef.current)
}, [])
return <input onChange={handleChange} />
}
The same pattern applies to requestAnimationFrame IDs, setInterval IDs,
WebSocket instances, and Intersection/ResizeObserver instances — anything that
needs to be cancelled/destroyed on unmount but doesn't affect the render output.
Rule of thumb: Anything you'd cancel in cleanup belongs in a ref, not state — timer IDs and resource handles are bookkeeping, not UI data.
The standard approach is React.forwardRef in the child, which allows the
parent to pass a ref that the child attaches to its DOM element.
// Child forwards ref to its inner <div>
const Card = React.forwardRef(({ title, children }, ref) => (
<div ref={ref} className="card">
<h2>{title}</h2>
{children}
</div>
))
// Parent attaches ref and can read the DOM node
function Page() {
const cardRef = useRef(null)
const scrollIntoView = () => {
cardRef.current?.scrollIntoView({ behavior: 'smooth' })
}
return (
<>
<button onClick={scrollIntoView}>Scroll to card</button>
<Card ref={cardRef} title="Target">content</Card>
</>
)
}
For class components, a plain ref already points to the class instance, so
you can call public methods directly without forwardRef. For function
components, forwardRef + optionally useImperativeHandle is the only way.
Rule of thumb: Always use forwardRef in any reusable function component
that wraps a DOM element — parents often need scroll, focus, or measurement access.
Refs are an imperative escape hatch — reaching for them frequently is a sign the component is fighting React's declarative model instead of working with it.
// Smell: using ref to read input value instead of controlled state
function BadForm() {
const inputRef = useRef(null)
const submit = () => console.log(inputRef.current.value) // side-channel read
return <><input ref={inputRef} /><button onClick={submit}>Send</button></>
}
// Better: controlled component — value lives in React state
function GoodForm() {
const [value, setValue] = useState('')
const submit = () => console.log(value) // just reads state
return (
<>
<input value={value} onChange={e => setValue(e.target.value)} />
<button onClick={submit}>Send</button>
</>
)
}
Other ref-as-smell patterns: using a ref to trigger a re-render manually (use
state), using a ref to share data between sibling components (lift state or use
context), and using refs to replicate what useEffect dependencies should handle.
Rule of thumb: If you're reaching for a ref to solve a problem involving rendering or data flow, step back — there's almost certainly a declarative solution using state, context, or derived values.
Refs are mutable and not part of React's render model. React may render a component multiple times before committing (strict mode renders twice in dev; concurrent mode may discard renders). Refs are only reliably up to date after commit (i.e. inside effects and event handlers).
function Broken() {
const ref = useRef(0)
ref.current++ // mutated during render — unreliable in concurrent mode
// If React renders this component but discards the output,
// ref.current has been incremented but nothing was committed.
return <p>Renders: {ref.current}</p> // stale / unpredictable
}
// Correct: mutate refs only in effects or event handlers
function Fixed() {
const ref = useRef(0)
useEffect(() => { ref.current++ }) // runs only after commit
return <p>...</p>
}
Reading a ref in JSX (displaying ref.current) is also fragile because React
won't schedule a re-render when .current changes, so the displayed value goes
stale silently.
Rule of thumb: Treat refs as post-commit bookkeeping — read and write .current
only inside effects and event handlers, never directly in the render function body.
Both approaches can place a modal high in the DOM, but they have different tradeoffs for component co-location and CSS context.
// Option A: lift modal to App — modal logic scattered from its trigger
function App() {
const [open, setOpen] = useState(false)
return (
<>
{open && <Modal onClose={() => setOpen(false)} />} {/* far from trigger */}
<DeepTree onTrigger={() => setOpen(true)} />
</>
)
}
// Option B: portal — modal stays co-located with its trigger, escapes DOM
function TriggerButton() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open Modal</button>
{open && <Modal onClose={() => setOpen(false)} />} {/* portal inside */}
</>
)
}
Portals let you keep state and logic co-located with the component that owns them while still rendering the output at the top of the DOM. Lifting state achieves DOM placement but couples the modal logic to a distant ancestor.
Rule of thumb: Use a portal when co-location matters and a CSS escape is needed; lift state to the root only when multiple unrelated components need to control the same modal.
Attaching a ref to a DOM element gives you the raw HTMLElement, so you can
call any imperative DOM API — scrollIntoView, getBoundingClientRect,
offsetHeight, etc. Measurements must happen after the element has rendered,
which means inside a useEffect or an event handler.
function ChatWindow({ messages }) {
const bottomRef = useRef(null)
const containerRef = useRef(null)
const [containerHeight, setContainerHeight] = useState(0)
// Scroll to bottom whenever messages change
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Measure container after first render
useEffect(() => {
if (containerRef.current) {
setContainerHeight(containerRef.current.getBoundingClientRect().height)
}
}, [])
return (
<div ref={containerRef} className="chat-window">
{messages.map(m => <Message key={m.id} data={m} />)}
<div ref={bottomRef} /> {/* sentinel element at the bottom */}
</div>
)
}
For layout measurements that need to happen before the browser paints, use
useLayoutEffect instead of useEffect — it fires synchronously after DOM
mutations but before the browser renders, avoiding a visible flash.
Rule of thumb: Use useEffect for scroll commands (visual, after paint is
fine) and useLayoutEffect for measurements that affect what gets rendered,
since those must happen before the user sees the result.
Yes — you can have as many portals as you like. Each createPortal call is
independent and can target the same or different DOM nodes.
// index.html — dedicated mount points alongside #root
// <div id="root"></div>
// <div id="modal-root"></div>
// <div id="tooltip-root"></div>
function Tooltip({ content, anchor }) {
return createPortal(
<div className="tooltip">{content}</div>,
document.getElementById('tooltip-root') // dedicated node
)
}
function Modal({ children }) {
return createPortal(
<div className="modal-overlay">{children}</div>,
document.getElementById('modal-root') // separate node for stacking control
)
}
Separating mount nodes gives you CSS stacking control between portal types —
#modal-root can have a higher z-index base than #tooltip-root at the body
level, without needing per-element z-index wars.
Rule of thumb: Provision a dedicated <div> for each class of portal
(modals, tooltips, toasts) in index.html so their stacking order is
determined structurally, not by arbitrary z-index values.
More Patterns interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.