30% offEnding soon

React Portals: A Practical Interview Guide

18 min read

An interview answer becomes stronger when it separates portal placement from the focus, dismissal, background, and cleanup behavior required by a real modal.

A React portal renders children into a different DOM container without removing them from their original React component tree.

What Are React Portals?

A React portal contains React children whose physical DOM nodes appear under a different DOM container. The component that creates the portal still owns those children.

Suppose an application renders a modal component inside a card:

<App>
  <Card>
    <Modal />
  </Card>
</App>

Without a portal, the modal's DOM normally appears inside the card's DOM. That placement may be troublesome if the card clips overflowing content or establishes a stacking context that prevents the modal from covering the page.

A portal can place the modal under a container near document.body instead:

body
├── #app-root
│   └── .card
└── #portal-root
    └── .modal

This produces two useful ways to describe the same modal:

React treeDOM treeAppCardModalis owned herebody#app-root#portal-root.card.modalappears hereOne modal, two views
A portal changes the modal’s DOM parent while preserving its React parent.
  • In the React tree, Modal remains a child of Card.
  • In the DOM tree, the modal elements are children of #portal-root.

That distinction explains most portal interview questions. Context supplied above Card remains available inside Modal. State and callbacks passed through props continue to work. Events also propagate according to the React tree, which can surprise someone inspecting only the DOM.

A portal does not make its content modal. It does not add dialog semantics, move focus, contain Tab navigation, close on Escape, restore focus, lock scrolling, or prevent interaction with the background. Those behaviors belong to the component that uses the portal.

For a smaller implementation exercise, the site's focused React Portal question is useful before attempting the full modal in this guide.

How createPortal Works

Import createPortal from react-dom:

import { createPortal } from 'react-dom';

Its call signature is:

createPortal(children, domNode, key?)

children is the React content to render. domNode is the destination DOM element. The optional key helps React distinguish portals when a component produces more than one.

The function returns a React node. A component can return that node directly or include it in other JSX:

function StatusMessage({ host }) {
  return createPortal(
    <p role="status">Tests passed</p>,
    host
  );
}

The destination must already exist when createPortal runs. Passing null, reading document during a server render, or creating a fresh destination during every render produces an invalid or unstable implementation.

Keep the destination stable. If an update passes a different DOM node, React recreates the portal content in the new destination. Local DOM state and focus can be lost as that content is replaced.

Destination identity mattersSame host node#portal-rootbefore updatesame nodefocus remainsDifferent host nodeold hostcontent removednew hostfresh contentLocal state starts over
A stable host preserves the mounted content; a new host causes replacement.

Portal children still belong to the surrounding React tree. A modal can therefore read a theme from an ancestor context, as it could in a ThemeProvider exercise, even though its DOM appears outside the provider's DOM element.

Use createPortal when JSX belongs to an existing React tree but needs another DOM location. A separate createRoot starts another React tree, so it changes ownership rather than only changing placement.

When Should You Use a Portal?

Use a portal when the required DOM placement cannot be achieved reliably inside the component's current container. Common examples include modals, tooltips, toasts, hovercards, and a shared overlay layer.

The destination matters. Moving an overlay outside a clipping ancestor can prevent that ancestor from cutting it off. The new destination may still sit inside another stacking context, so a portal does not guarantee that every z-index problem disappears.

The following comparison gives a practical selection rule:

ApproachReact ownershipDOM placementGood fitMain limitation
Normal rendering with CSSExisting React treeCurrent component containerContent that can remain inside its parentAncestor clipping or stacking may still interfere
React portalExisting React treeAnother existing DOM containerOverlays or integration points that need different placementModal behavior and accessibility remain your responsibility
Separate React rootNew React treeThe root's DOM containerAn independent React application or isolated entry pointExisting context and React ancestry do not continue automatically
Native dialog with showModal()Depends on where React renders itBrowser-managed top layer while modalModal behavior that benefits from the top layer and background inertnessIt is an element API, not a replacement for every portal use case

Try ordinary rendering first when CSS can solve the placement problem. An absolutely or fixed-positioned child may be sufficient when no ancestor clips it or confines its stacking behavior.

Choose a portal when the JSX must retain its current state, context, and parent callbacks but needs a different DOM parent. A global toast layer and a hovercard that must escape an overflow container fit this rule.

Choose a separate root when the rendered interface is genuinely independent. Do not reach for createRoot merely to place an existing component elsewhere.

Consider the native dialog element for modal behavior. Calling showModal() places a connected dialog in the document's top layer and makes the rest of that document inert while the dialog is active. A portal and a native dialog solve different parts of the problem. A portal changes placement within React's rendering model, while showModal() activates browser modal behavior.

React portalNative dialogDOM pageCard ownsModalportal hostnew locationPlacement changesBackground inertdialogtop layeractiveModality activates
Portals relocate React content; showModal activates browser-managed modality.

Build an Accessible Portal Modal

A realistic interview prompt might read:

Build a modal with createPortal. Move focus into it when it opens, contain Tab navigation, close it on Escape or a backdrop click, restore focus to the opener or a logical fallback, prevent interaction with the designated #app-root background region, lock body scrolling, and clean up every side effect. The code must tolerate server rendering and Strict Mode checks.

Hidden cases should include:

  • The portal container is absent when the application starts.
  • The user presses Shift+Tab from the first control.
  • The user clicks inside the dialog instead of on the backdrop.
  • The component unmounts while the modal is open.
  • Strict Mode repeats Effect setup and cleanup during development.
  • A server render runs without document.

Stop after the prompt and hidden cases during the candidate portion; the candidate writes the component and tests without seeing the answer. The following is the interviewer’s reference solution. It uses the tabbable package, creates or reuses a destination in an Effect, and removes a hook-created host only after its final consumer unmounts.

import {
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';
import { createPortal } from 'react-dom';

function usePortalHost(id) {
  const [host, setHost] = useState(null);

  useEffect(() => {
    let node = document.getElementById(id);
    let ownsNode = managedPortalHostUsers.has(id);

    if (!node) {
      node = document.createElement('div');
      node.id = id;
      document.body.append(node);
      ownsNode = true;
    }

    setHost(node);

    return () => {
      if (ownsNode) {
        node.remove();
      }
    };
  }, [id]);

  return host;
}

export function Modal({
  open,
  onClose,
  title = 'Practice result',
  appRootId = 'app-root',
}) {
  const host = usePortalHost('portal-root');
  const dialogRef = useRef(null);

  useEffect(() => {
    if (!open || !host) {
      return undefined;
    }

    const opener =
      document.activeElement instanceof HTMLElement
        ? document.activeElement
        : null;
    const appRoot = document.getElementById(appRootId);
    const dialog = dialogRef.current;
    const releaseModalState = acquireModalState(appRoot);

    const getFocusableElements = () => {
      if (!dialog) {
        return [];
      }

      return tabbable(dialog);
    };

    const firstControl = getFocusableElements()[0];
    (firstControl ?? dialog)?.focus();

    function handleKeyDown(event) {
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
        return;
      }

      if (event.key !== 'Tab' || !dialog) {
        return;
      }

      const focusable = getFocusableElements();

      if (focusable.length === 0) {
        event.preventDefault();
        dialog.focus();
        return;
      }

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (
        !event.shiftKey &&
        document.activeElement === last
      ) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      releaseModalState();

      const openerCanReceiveFocus =
        opener?.isConnected &&
        !opener.matches(':disabled, [hidden]') &&
        !opener.closest(
          '[inert], [hidden], [aria-hidden="true"]'
        );
      const focusTarget = openerCanReceiveFocus
        ? opener
        : document.getElementById(appRootId);

      focusTarget?.focus();
    };
  }, [appRootId, host, onClose, open]);

  if (!open || !host) {
    return null;
  }

  function handleBackdropClick(event) {
    if (event.target === event.currentTarget) {
      onClose();
    }
  }

  return createPortal(
    <div
      data-testid="modal-backdrop"
      onClick={handleBackdropClick}
      style={{
        position: 'fixed',
        inset: 0,
        zIndex: 1000,
        display: 'grid',
        placeItems: 'center',
        padding: '1rem',
        background: 'rgb(0 0 0 / 60%)',
      }}
    >
      <section
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        style={{
          width: 'min(32rem, 100%)',
          padding: '1.5rem',
          borderRadius: '0.75rem',
          background: '#fff',
          color: '#111',
          boxShadow: '0 1.5rem 4rem rgb(0 0 0 / 35%)',
        }}
      >
        <h2>{title}</h2>
        <p>Review the failed cases before submitting again.</p>
        <button type="button" onClick={onClose}>
          Close
        </button>
        <button type="button" onClick={onClose}>
          Confirm review
        </button>
      </section>
    </div>,
    host
  );
}

export default function App() {
  const [open, setOpen] = useState(false);
  const closeModal = useCallback(() => setOpen(false), []);

  return (
    <main id="app-root" tabIndex={-1}>
      <h1>Portal modal practice</h1>
      <button type="button" onClick={() => setOpen(true)}>
        Open practice result
      </button>

      <Modal open={open} onClose={closeModal} />
    </main>
  );
}

usePortalHost reads document only inside an Effect. Effects do not run during server rendering, so the server output contains no portal. The first client render also has host === null. After the Effect finds or creates the destination, React renders the portal.

Every mutation has a matching cleanup. The document listener is removed. The previous body overflow value is restored. The application root's earlier inert state is preserved. Focus returns to the element that was active before the modal opened when it remains available; otherwise, focus moves to the application root.

The backdrop handler compares event.target with event.currentTarget. A click directly on the backdrop closes the modal. A click on the dialog has a different target and remains open.

The portal supplies placement only. The role, accessible name, focus entry, Tab handling, Escape handling, focus restoration, inert attribute, and scroll mutation supply the modal behavior.

What makes a portal modal?Page is inertOpenerFocus returns on closeNamed dialogFirst controlLast controlShift+ TabTabScroll locked
A portal positions the dialog, while coordinated behaviors make it modal.

For repeated practice with complete solutions and runnable tests, UIReady Premium Lifetime can be useful after the free portal exercise exposes which hidden cases were missed.

Portal Edge Cases Interviewers Test

Portal events bubble through the React tree. Consider a parent component with an onClick handler that renders a portal below it in JSX. Clicking the portal dialog can run that parent handler even though the dialog is not inside the parent's DOM element.

A portal click has two mapsDOM locationReact event path#portal-rootClick insideportal dialogModalCard handlerApp ancestorThe click follows ownership upward
Portal events bubble through the React tree, not toward the portal’s DOM parent only.

Do not add stopPropagation() to every portal click as a default fix. That can suppress behavior an ancestor legitimately owns. First decide whether the parent handler should ignore particular targets, whether the handler belongs lower in the tree, or whether propagation should stop at a specific boundary.

Backdrop dismissal is a related but different problem. Checking event.target === event.currentTarget distinguishes the backdrop itself from its descendants without suppressing all bubbling events.

Server rendering introduces another boundary. Code that reads document, finds a destination, or appends an element must run on the client. An Effect provides that client boundary, but the component must also render matching initial content before the Effect runs.

A changing destination recreates the portal content. Avoid expressions that create a new element during render:

createPortal(children, document.createElement('div'))

That element is detached unless something appends it, and another render produces another destination. Use a stable existing node or create one through a lifecycle with cleanup.

Strict Mode performs an extra development setup and cleanup cycle for Effects. The modal code remains safe because setup and cleanup are paired. Missing cleanup would leave duplicate listeners, orphaned portal hosts, stale body styles, or incorrect focus.

A portal can escape an ancestor only when its target lies outside that ancestor. The destination can still participate in a stacking context of its own. Portals relocate DOM nodes, but they do not make stacking rules irrelevant.

These distinctions also appear in broader React interview questions, where interviewers often care more about ownership and cleanup than memorizing the API signature.

How to Test React Portals

Testing Library's screen queries search document.body, so they can find portal content outside the container created by render. Prefer role and accessible-name queries for the dialog and its controls.

Where screen searchesdocument.bodyrender containerApp andopener#portal-rootNameddialogOne query boundary includes both
screen can find portal UI anywhere under document.body.

These tests target the canonical implementation:

import '@testing-library/jest-dom/vitest';
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import { afterEach, expect, test, vi } from 'vitest';
import {
  cleanup,
  fireEvent,
  render,
  screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App, { Modal } from './PortalModal';

afterEach(() => {
  cleanup();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

test('does not read document during server rendering', () => {
  vi.stubGlobal('document', undefined);

  expect(() =>
    renderToString(<Modal open onClose={() => {}} />)
  ).not.toThrow();
});

test('survives Strict Mode setup and cleanup', async () => {
  const opener = document.createElement('button');
  opener.textContent = 'External opener';
  document.body.append(opener);
  opener.focus();

  const addListener = vi.spyOn(document, 'addEventListener');
  const removeListener = vi.spyOn(document, 'removeEventListener');
  const { unmount } = render(
    <StrictMode>
      <div id="app-root">
        <Modal open onClose={() => {}} />
      </div>
    </StrictMode>
  );

  await screen.findByRole('dialog', {
    name: 'Practice result',
  });

  const appRoot = document.getElementById('app-root');
  const liveKeydownListeners = () =>
    addListener.mock.calls.filter(([type]) => type === 'keydown')
      .length -
    removeListener.mock.calls.filter(
      ([type]) => type === 'keydown'
    ).length;

  expect(document.querySelectorAll('#portal-root')).toHaveLength(1);
  expect(liveKeydownListeners()).toBe(1);
  expect(appRoot).toHaveAttribute('inert');
  expect(document.body.style.overflow).toBe('hidden');

  unmount();

  expect(document.getElementById('portal-root')).toBeNull();
  expect(liveKeydownListeners()).toBe(0);
  expect(appRoot).not.toHaveAttribute('inert');
  expect(document.body.style.overflow).toBe('');
  expect(opener).toHaveFocus();
  opener.remove();
});

test('cleans up when unmounted while open', async () => {
  const opener = document.createElement('button');
  opener.textContent = 'External opener';
  document.body.append(opener);
  opener.focus();

  const removeListener = vi.spyOn(document, 'removeEventListener');
  const { unmount } = render(
    <div id="app-root">
      <Modal open onClose={() => {}} />
    </div>
  );

  await screen.findByRole('dialog', {
    name: 'Practice result',
  });

  const appRoot = document.getElementById('app-root');
  unmount();

  expect(document.getElementById('portal-root')).toBeNull();
  expect(appRoot).not.toHaveAttribute('inert');
  expect(document.body.style.overflow).toBe('');
  expect(
    removeListener.mock.calls.some(([type]) => type === 'keydown')
  ).toBe(true);
  expect(opener).toHaveFocus();
  opener.remove();
});

test('renders a named dialog under the portal host', async () => {
  const user = userEvent.setup();
  render(<App />);

  await user.click(
    screen.getByRole('button', {
      name: 'Open practice result',
    })
  );

  const dialog = screen.getByRole('dialog', {
    name: 'Practice result',
  });

  expect(dialog.closest('#portal-root')).not.toBeNull();
  expect(
    screen.getByRole('button', { name: 'Close' })
  ).toHaveFocus();
});

test('contains focus and closes on Escape', async () => {
  const user = userEvent.setup();
  render(<App />);

  const opener = screen.getByRole('button', {
    name: 'Open practice result',
  });
  await user.click(opener);

  const close = screen.getByRole('button', { name: 'Close' });
  const confirm = screen.getByRole('button', {
    name: 'Confirm review',
  });

  await user.tab({ shift: true });
  expect(confirm).toHaveFocus();

  await user.tab();
  expect(close).toHaveFocus();

  await user.keyboard('{Escape}');
  expect(screen.queryByRole('dialog')).toBeNull();
  expect(opener).toHaveFocus();
});

test('closes only when the backdrop itself is clicked', async () => {
  const user = userEvent.setup();
  render(<App />);

  await user.click(
    screen.getByRole('button', {
      name: 'Open practice result',
    })
  );

  fireEvent.click(
    screen.getByRole('dialog', { name: 'Practice result' })
  );
  expect(screen.getByRole('dialog')).not.toBeNull();

  fireEvent.click(screen.getByTestId('modal-backdrop'));
  expect(screen.queryByRole('dialog')).toBeNull();
});

test('restores background state and removes its portal host', async () => {
  const user = userEvent.setup();
  const { unmount } = render(<App />);

  await user.click(
    screen.getByRole('button', {
      name: 'Open practice result',
    })
  );

  expect(document.getElementById('app-root')).toHaveAttribute(
    'inert'
  );
  expect(document.body.style.overflow).toBe('hidden');

  await user.keyboard('{Escape}');

  expect(
    document.getElementById('app-root')
  ).not.toHaveAttribute('inert');
  expect(document.body.style.overflow).toBe('');

  unmount();
  expect(document.getElementById('portal-root')).toBeNull();
});

test('portal clicks bubble through the React tree', () => {
  const parentClick = vi.fn();

  render(
    <div id="app-root" onClick={parentClick}>
      <Modal open onClose={() => {}} />
    </div>
  );

  fireEvent.click(
    screen.getByRole('dialog', { name: 'Practice result' })
  );

  expect(parentClick).toHaveBeenCalledTimes(1);
});

The last test protects a React ownership rule, not a DOM layout detail. If someone moves the handler, adds broad propagation suppression, or replaces the portal with an independent root, that observable behavior may change.

A good interview test suite checks behavior through document.body rather than assuming the dialog sits inside the render container. The same habit applies to other browser-based interview coding examples: test what the user can find, focus, dismiss, and activate.

React Portals Interview Checklist

A strong explanation should cover these invariants:

  • createPortal(children, domNode, key?) returns a React node.
  • The destination exists before createPortal receives it.
  • The DOM parent changes, while React ownership remains.
  • Context continues through the React tree.
  • Events bubble through React ancestry.
  • A different destination recreates the portal content.
  • A portal does not supply modal accessibility by itself.
  • Server rendering avoids reading document.
  • Effect setup has matching cleanup.

A ten-point implementation rubric can award one point for each of the following: stable destination, dialog name, initial focus, contained Tab order, Escape dismissal, backdrop discrimination, focus restoration, background handling, symmetric cleanup, and behavior-based tests. A solution that renders outside the application root but misses focus and cleanup has solved placement, not the full modal task.

Frequently asked questions

What is a React portal?
A React portal renders React-owned children into a different DOM container. The DOM parent changes, but context, state relationships, and event propagation still follow the React component tree.
Do React portals make modals accessible?
No. A portal only changes where DOM nodes are placed. Modal semantics, focus entry, contained tab order, Escape handling, focus restoration, background behavior, and scroll locking must be implemented separately.
Why do click events from a portal reach a parent component?
React propagates portal events through the React component tree. A parent handler can therefore receive a bubbling event even when the portal DOM is outside that parent's DOM element.
Can React portals be used with server rendering?
Yes, but server rendering cannot run code that reads document. Create or locate the portal container in a client Effect, and keep the initial server and client output the same until that Effect runs.