Why portals and refs come up in senior React interviews
React's declarative model handles 95% of UI work beautifully. Portals and refs exist for the other 5% — the cases where you genuinely need to step outside React's rendering model and talk to the DOM directly. Interviewers ask about them because they reveal whether a candidate understands React's architecture at a deep level, not just how to write JSX.
Questions range from "what does createPortal do?" to subtle gotchas like "how do events
bubble through a portal?" to design-sense questions like "when do refs indicate a code
smell?" This guide covers all of them.
React portals — the DOM escape hatch
What createPortal does
ReactDOM.createPortal(children, domNode) renders children into a DOM node of your
choosing — typically somewhere outside the main app root — while keeping those children
fully inside the React component tree. Context, state, refs, and synthetic events all
flow through the React ancestry unchanged. Only the DOM placement is different.
import { createPortal } from 'react-dom'
function Modal({ children, onClose }) {
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.getElementById('modal-root') // outside #root in the real DOM
)
}
Why you need portals: the z-index trap
The canonical reason to use a portal is to escape a CSS stacking context. When an
ancestor element has transform, opacity < 1, or position + z-index, it creates a
new stacking context. Every descendant's z-index is relative to that context, not to
the page root. A modal with z-index: 9999 can still be covered by a sibling stacking
context with z-index: 2.
Portaling the modal to document.body removes it from all ancestor stacking contexts,
letting it float above everything else without fighting z-index wars.
The same logic applies to overflow: hidden parents clipping tooltips and dropdown
menus. The fix is always the same: portal the overlay to a node outside the clipping
ancestor.
The event bubbling gotcha — the #1 interview trick
This is the question most candidates get wrong. Events inside a portal bubble through the React component tree, not the actual DOM tree.
function Page() {
// This onClick fires when the button inside the portal is clicked.
// The portal DOM is outside #root, but its React parent is Page.
return (
<div onClick={() => console.log('caught in Page')}>
<Modal>
<button>Click me</button>
</Modal>
</div>
)
}
React's synthetic event system delegates to the React root, not to DOM nodes, so it
follows the React ancestry. This is almost always what you want — the component that
renders the portal should be able to catch events from it. But it surprises developers
who expect events to bubble through the DOM, especially when mixing React synthetic events
with native addEventListener calls.
Accessibility in portal modals
A portal handles the DOM placement but not the accessibility. You must add these yourself:
role="dialog"andaria-modal="true"so screen readers treat it as a dialog- Move focus into the modal when it opens (
ref.current.focus()on the dialog container withtabIndex={-1}) - Trap focus inside the modal while it's open (the
focus-trap-reactlibrary automates this) - Close on
Escapekey via adocument.addEventListener('keydown', ...)in auseEffect
const Modal = React.forwardRef(({ isOpen, onClose, children }, ref) => {
useEffect(() => {
if (!isOpen) return
const handler = (e) => e.key === 'Escape' && onClose()
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div role="dialog" aria-modal="true" tabIndex={-1} ref={ref}>
{children}
</div>,
document.body
)
})
React automatically removes portal DOM when the component unmounts. If you created the
host container element yourself (via document.createElement), you're responsible for
removing that container in cleanup.
Refs — mutable values that don't drive renders
The two jobs of useRef
useRef(initialValue) returns { current: initialValue } — a plain object that persists
across renders. Changing .current does not schedule a re-render, which is exactly
the point.
Job 1 — DOM access: attach ref to a JSX element to get the underlying DOM node.
function TextInput() {
const inputRef = useRef(null)
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Focus</button>
</>
)
}
Job 2 — mutable container: store any value that needs to survive re-renders without causing them — timer IDs, animation frame IDs, WebSocket instances, previous prop values.
function Debounced({ onSearch }) {
const timerRef = useRef(null)
const handleChange = (e) => {
clearTimeout(timerRef.current) // cancel previous — no re-render
timerRef.current = setTimeout(() => {
onSearch(e.target.value)
}, 300)
}
useEffect(() => () => clearTimeout(timerRef.current), [])
return <input onChange={handleChange} />
}
Refs vs state — the core mental model
| State | Ref | |
|---|---|---|
| Persists across renders | Yes | Yes |
| Triggers re-render on change | Yes | No |
| Update timing | Batched, asynchronous | Synchronous |
| Use for | UI values that appear in JSX | Bookkeeping invisible to the UI |
The decision is simple: if a value needs to be displayed in the render output or control conditional rendering, it must be state. If it's behind-the-scenes bookkeeping (timers, measurements, previous values), a ref keeps the render cycle clean.
Callback refs — knowing when a node appears
A callback ref is a function you pass to the ref prop. React calls it with the DOM node
on mount and null on unmount — it notifies you when the attachment changes, unlike a
ref object which is silently populated after render.
function MeasuredBox() {
const [height, setHeight] = useState(0)
const measuredRef = useCallback((node) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height)
}
}, [])
return <div ref={measuredRef}>Height: {height}px</div>
}
Use a callback ref when you need to react to the node being attached or detached — measuring conditional elements, initialising third-party libraries, or observing nodes that come and go.
Ref forwarding and useImperativeHandle
By default, ref on a custom component is not forwarded. Use React.forwardRef to pass
the ref through to a DOM element inside the component.
const FancyInput = React.forwardRef((props, ref) => (
<input {...props} ref={ref} className="fancy" />
))
When you want to expose a custom API rather than raw DOM access, combine forwardRef
with useImperativeHandle:
const VideoPlayer = React.forwardRef((props, ref) => {
const videoRef = useRef(null)
useImperativeHandle(ref, () => ({
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
}), [])
return <video ref={videoRef} src={props.src} />
})
This exposes only play and pause — not the full HTMLVideoElement — enforcing an
intentional API boundary.
When refs are a design smell
Reaching for refs often signals that a component is fighting React rather than working with it:
- Reading input values via ref instead of state — use a controlled component
- Triggering a re-render by writing to a ref — use
setStateinstead - Sharing data between siblings via ref — lift state or use context
- Replicating what effect dependencies should handle — fix the dependency array
If you find yourself writing ref.current = someValue to communicate between components
or drive rendering logic, there's almost certainly a declarative solution.
One important subtlety: never mutate refs during render. React's concurrent mode may
render a component multiple times without committing — each discarded render would
mutate the ref, leaving it in an inconsistent state. Always write to .current inside
effects or event handlers, after commit.
Key Takeaways
createPortalrenders children at a different DOM location while keeping them in the React component tree — context, events, and state all still flow from React ancestors.- Events bubble through the React tree, not the DOM tree — a portal's click events reach its React parent even though the DOM is elsewhere. This is the top interview gotcha.
- Use portals for modals, tooltips, and dropdowns that need to escape
z-indexstacking contexts oroverflow: hiddenclipping from ancestor elements. - Always add
role="dialog",aria-modal="true", and focus management manually — portals do not provide accessibility automatically. useRefhas two jobs: DOM element access and mutable value storage across renders without triggering re-renders.- Prefer state when a value drives the UI; use a ref for behind-the-scenes bookkeeping like timer IDs, previous values, and resource handles.
- Use a callback ref instead of
useRefwhen you need to run code the moment a DOM node appears or disappears. - Use
forwardRefto expose a DOM node from a function component; adduseImperativeHandleto expose a controlled imperative API instead of raw DOM access. - Frequent ref usage is a design smell — it often means state, context, or better effect dependency management would solve the problem declaratively.