Skip to content

React · Patterns

React Portals & Refs — Complete Interview Guide

8 min read Updated 2026-06-24 Share:

Practice Portals & Refs interview questions

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" and aria-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 with tabIndex={-1})
  • Trap focus inside the modal while it's open (the focus-trap-react library automates this)
  • Close on Escape key via a document.addEventListener('keydown', ...) in a useEffect
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

StateRef
Persists across rendersYesYes
Triggers re-render on changeYesNo
Update timingBatched, asynchronousSynchronous
Use forUI values that appear in JSXBookkeeping 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 setState instead
  • 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

  • createPortal renders 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-index stacking contexts or overflow: hidden clipping from ancestor elements.
  • Always add role="dialog", aria-modal="true", and focus management manually — portals do not provide accessibility automatically.
  • useRef has 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 useRef when you need to run code the moment a DOM node appears or disappears.
  • Use forwardRef to expose a DOM node from a function component; add useImperativeHandle to 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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel