React NotesRohit’s interview study guide
Chapter 06

Refs, the DOM & Portals

useRef for DOM access and for values that survive renders, forwardRef (React 18 and earlier) versus ref as a prop (React 19), useImperativeHandle, callback refs with React 19's ref cleanup, and portals for modals and tooltips.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#useRef

From my notes: useRef is a hook that gives you a value that persists across renders without triggering a re-render when you change it. It has two jobs: holding a reference to a DOM element (imperative access), and holding mutable values that the screen doesn't depend on (timer ids, previous values, "is this the first run?").

const ref = useRef(initialValue) returns a plain object, { current: initialValue }. React gives you the same object on every render of that component, and never looks inside it. The mental model: state is a value React watches — change it and React re-renders; a ref is a pocket on the component — you can put anything in it and take it out later, and React doesn't notice. My notes then said "Below example is to persist value even when rerenders and in itself useRef doesn't cause rerenders" — the example itself was a screenshot that didn't come through, so here is one that shows exactly that.

RefVsState.jsx
import { useRef, useState } from "react";

function RefVsState() {
  const [stateCount, setStateCount] = useState(0);
  const refCount = useRef(0);
  console.log(`render: state=${stateCount}, ref=${refCount.current}`);

  return (
    <>
      <button onClick={() => { refCount.current += 1; console.log(`ref is now ${refCount.current}`); }}>
        Ref +1
      </button>
      <button onClick={() => setStateCount(stateCount + 1)}>State +1</button>
      <p>state {stateCount} / ref {refCount.current}</p>
    </>
  );
}
// render: state=0, ref=0
// click "Ref +1" twice → ref is now 1, ref is now 2   (screen still: state 0 / ref 0)
// click "State +1"     → render: state=1, ref=2        (screen: state 1 / ref 2)
What’s happening
  1. First render: useRef(0) creates { current: 0 } and React stores it with this component. The screen shows state 0 / ref 0.
  2. Clicking "Ref +1" changes refCount.current to 1, then 2. The value really changes (the logs prove it), but no render: line appears and the screen still says ref 0: mutating a ref doesn't schedule a render.
  3. Clicking "State +1" calls a state setter, so React re-renders. useRef(0) now returns the same object as before — the 0 argument is ignored after the first render — so refCount.current is still 2.
  4. The screen jumps to state 1 / ref 2. The ref value had been sitting there, preserved across renders, waiting for something else to cause a render. (The test also checked that the ref object is identical across renders: true.)
  5. Rule of thumb: if the value is shown on screen, it's state. If it's only used by event handlers or effects, a ref is fine.
useStateuseRef
Returns[value, setValue]{ current: value }
Changing it re-renders?YesNo
Mutable?No — call the setter with a new valueYes — assign ref.current
Read during render?Yes, that's the pointAvoid (except lazy init)
Typical useAnything the UI showsDOM nodes, timer ids, previous values, latest callbacks
AdvancedHow useRef is built

Conceptually, useRef is useState with a setter you never call:

React
function useRef(initialValue) {
  const [ref] = useState(() => ({ current: initialValue }));
  return ref;
}
What’s happening
  1. useState with an initialiser function creates the object once, on the first render, and returns the same object on every later render.
  2. Because the setter is never called, React never re-renders because of it. Mutating ref.current changes a property of an object React isn't tracking.
  3. This is why a ref behaves like an instance field on a class (this.something) — which is how the React docs describe it.

#Refs to DOM elements

React handles DOM updates for you, but some things can only be done imperatively on a real node: focusing an input, selecting text, scrolling to an element, measuring its size, playing a video, or handing the node to a non-React library. For that, create a ref and pass it as the ref attribute of a JSX element. React puts the DOM node into ref.current when the element is added, and sets it back to null when it's removed.

Think of it as asking React for the node's address: you leave an empty envelope (useRef(null)) on the element, and after React has built the DOM, the envelope contains the node.

SearchBox.jsx
function SearchBox() {
  const inputRef = useRef(null);

  return (
    <>
      <input ref={inputRef} placeholder="Search" />
      <button onClick={() => inputRef.current.focus()}>Focus search</button>
    </>
  );
}
// click "Focus search" → the input has focus
What’s happening
  1. useRef(null) creates { current: null }. During the first render there's no DOM node yet, so null is the honest starting value.
  2. ref={inputRef} tells React: "when you create this <input>, store the node in inputRef.current". React does that during the commit, after inserting the element.
  3. When the button is clicked, inputRef.current is the real HTMLInputElement, and .focus() is the browser's own DOM method. The test checked toHaveFocus().
  4. React itself didn't re-render for any of this: the ref was filled in silently, and focusing is a DOM operation outside React's state.

From my notes, an uncontrolled input — the form reads the DOM value through a ref instead of keeping it in state:

UncontrolledInput.jsx
function UncontrolledInput() {
  const inputRef = useRef(null);
  const [submitted, setSubmitted] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    setSubmitted(inputRef.current.value); // read the DOM, once, on submit
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="" aria-label="name" />
      <button type="submit">Submit</button>
      <p>{submitted}</p>
    </form>
  );
}
// type "Rohit", submit → "Rohit" appears below
What’s happening
  1. defaultValue sets the input's initial value but doesn't control it — the browser owns the value from then on. Typing doesn't re-render the component at all.
  2. On submit, the handler reads inputRef.current.value directly from the DOM node: "Rohit".
  3. Only then is state updated, so the component renders once to show the result.
  4. This is the trade-off from Chapter 3's controlled vs uncontrolled topic: fewer renders and less code, but you can't validate or transform on every keystroke. React Hook Form (Chapter 13) is built on this uncontrolled-plus-refs idea.
AdvancedWhen is ref.current set?
WhenIsItSet.jsx
function WhenIsItSet({ show }) {
  const ref = useRef(null);
  console.log(`render: ${ref.current?.tagName ?? null}`);
  useLayoutEffect(() => { console.log(`layout effect: ${ref.current?.tagName ?? null}`); });
  useEffect(() => { console.log(`effect: ${ref.current?.tagName ?? null}`); });
  return show ? <input ref={ref} /> : <p>hidden</p>;
}
// mount with show:  render: null, layout effect: INPUT, effect: INPUT
// show={false}:     render: INPUT, layout effect: null, effect: null
What’s happening
  1. During the first render, ref.current is null — the DOM doesn't exist yet. Never call ref.current.focus() in the component body.
  2. React attaches refs during the commit, before layout effects run. So both effects see the INPUT.
  3. On the next render (with show={false}), the body still sees the old INPUT: the ref hasn't been touched yet, because rendering doesn't change the DOM.
  4. During that commit React removes the input and sets ref.current = null, so both effects now see null. Code that uses a ref after a conditional render must handle null.

#Refs as instance variables

The second job of a ref: remember something between renders that isn't part of the UI. In a class you'd write this.intervalId = …; in a function component, a local let variable is re-created on every render and forgets its value, so you use a ref. Typical contents: interval and timeout ids, a WebSocket or third-party instance, the previous value of a prop, "has this effect already run?", and the latest version of a callback.

The mental model: local variables live for one render; refs live as long as the component.

Stopwatch.jsx
function Stopwatch() {
  const [startTime, setStartTime] = useState(null);
  const [now, setNow] = useState(null);
  const intervalRef = useRef(null);

  function handleStart() {
    setStartTime(Date.now());
    setNow(Date.now());
    clearInterval(intervalRef.current); // in case it's already running
    intervalRef.current = setInterval(() => setNow(Date.now()), 100);
  }

  function handleStop() {
    clearInterval(intervalRef.current);
  }

  useEffect(() => () => clearInterval(intervalRef.current), []); // stop on unmount

  const seconds = startTime != null && now != null ? (now - startTime) / 1000 : 0;
  return (
    <>
      <p>Time passed: {seconds.toFixed(1)}</p>
      <button onClick={handleStart}>Start</button>
      <button onClick={handleStop}>Stop</button>
    </>
  );
}
// Start, wait 1.5s → "Time passed: 1.5"; Stop, wait 2s → still "1.5", 0 timers alive
What’s happening
  1. startTime and now are state, because the elapsed time is shown on screen and must re-render every 100 ms.
  2. The interval id is a ref, because nothing on screen depends on it — it's only needed later by handleStop. Putting it in state would cause a pointless re-render when it's set.
  3. Start: the interval is created and its id is stored in intervalRef.current. Every 100 ms setNow re-renders; the ref keeps the id across all those renders. With fake timers, 1.5 s showed 1.5.
  4. Stop: handleStop runs in a later render's closure, but reads the same ref object, so it finds the id and clears the interval. Two more seconds later the screen still said 1.5 and no timers were left.
  5. The unmount effect clears the interval if the component goes away while running — same ref, same id.

Why a plain variable doesn't work:

StopwatchBroken.jsx
function StopwatchBroken() {
  const [count, setCount] = useState(0);
  let intervalId = null; // ❌ re-created as null on every render

  return (
    <>
      <p>{count}</p>
      <button onClick={() => { intervalId = setInterval(() => setCount((n) => n + 1), 100); }}>Start</button>
      <button onClick={() => { console.log(`stop sees intervalId=${intervalId}`); clearInterval(intervalId); }}>Stop</button>
    </>
  );
}
// Start, wait 300ms, Stop, wait 300ms → "stop sees intervalId=null", count 6, 1 timer alive
What’s happening
  1. Start assigns the id to intervalId — the variable of the render that was current when you clicked.
  2. The interval ticks and calls setCount, so the component re-renders. Each render runs let intervalId = null again and creates fresh handlers that close over the new null variable.
  3. Stop is the handler from the latest render, so it sees intervalId=null and clearInterval(null) does nothing. The test showed the count kept rising to 6 and the timer stayed alive.
  4. A ref fixes it because useRef returns the same object to every render, so every handler shares one current.
AdvancedKeeping the previous value

"What was this prop last render?" is a classic ref interview question:

usePrevious.js
function usePrevious(value) {
  const ref = useRef(undefined);
  useEffect(() => {
    ref.current = value; // runs after render, so render still sees the old value
  });
  return ref.current;
}
What’s happening
  1. During render, ref.current still holds the value from the previous commit, and that's what's returned.
  2. After the commit, the effect stores the current value for next time.
  3. This pattern is everywhere in older code, but it reads a ref during render, which React's docs discourage, and it's not correct under concurrent rendering where a render can be discarded. The recommended alternative stores the previous value in state and updates it during render (the prevUserId pattern from Chapter 4), or avoids "previous" entirely by acting in the event handler that caused the change.

#forwardRef (React 18 and earlier)

In React 18 and earlier, ref was not a prop. React pulled it out of the props before calling your component, so a function component had no way to receive one — <FancyInput ref={inputRef} /> simply didn't work (React 18.3.1 warned, checked in a scratch install: "Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?"). To let a parent reach a DOM node inside a child, you wrapped the child in forwardRef, which passes the ref as a second argument next to props.

From my notes: forwarding refs lets you pass a ref from a parent component to a child component, useful when the parent needs to interact with a DOM element inside the child — focusing a design-system input, for example. Think of forwardRef as a mail-forwarding service: the parent addresses the ref to the component, and the component forwards it to the real DOM element inside.

FancyInput.jsx
import React from "react";

const FancyInput = React.forwardRef((props, ref) => (
  <input ref={ref} {...props} />
));

function App() {
  const inputRef = React.useRef(null);

  const focusInput = () => {
    inputRef.current.focus();
  };

  return (
    <>
      <FancyInput ref={inputRef} placeholder="Fancy" />
      <button onClick={focusInput}>Focus Input</button>
    </>
  );
}
// click "Focus Input" → the <input> inside FancyInput has focus (works in React 19.3 too)
What’s happening
  1. React.forwardRef(render) returns a special component object (the test logged $$typeof: Symbol(react.forward_ref)). React calls render(props, ref) with the ref as a separate argument.
  2. App creates inputRef and passes it as ref={inputRef}. React doesn't put it in props; it hands it to the forwardRef render function as ref.
  3. FancyInput attaches it to the real <input>, so after commit inputRef.current is the DOM node. {...props} passes the remaining props (placeholder) through.
  4. Clicking calls .focus() on that node. The test ran this exact code (from my notes) on React 19.3 and the input got focus: forwardRef still works in 19.
  5. Status in React 19: react.dev says "In React 19, forwardRef is no longer necessary. Pass ref as a prop instead" and that it "will be deprecated in a future release." It isn't deprecated yet — no warning — so existing code keeps working.

#ref as a prop (React 19)

Added

React 19 made ref a regular prop for function components. A child just destructures ref from its props and puts it on an element — no wrapper, no second argument. It's the same idea as forwardRef with the ceremony removed. TypeScript types got simpler too: ref is typed like any other prop.

The mental model: a ref is now just a prop that happens to be filled in by React — you can pass it down, rename it, or forward it several levels, exactly as you would onChange.

MyInput.jsx
function MyInput({ label, ref, ...rest }) {
  return (
    <label>
      {label}
      <input ref={ref} {...rest} />
    </label>
  );
}

function Form() {
  const inputRef = useRef(null);
  return (
    <>
      <MyInput label="Email" ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>Focus email</button>
    </>
  );
}
// click "Focus email" → the email input has focus
What’s happening
  1. <MyInput ref={inputRef} /> — in React 19 the ref stays in props, so MyInput receives { label: "Email", ref: inputRef }.
  2. MyInput destructures it like any prop and passes it to the <input>. After commit, inputRef.current is the input node.
  3. The parent's click handler focuses it. The test confirmed toHaveFocus().
  4. A component that doesn't use the ref just ignores it: the test passed a ref to a plain component that never forwarded it, got ref.current === null, and React 19.3 logged nothing — the React 18 "Function components cannot be given refs" warning is gone. The test also logged that ref shows up in Object.keys(props).
  5. Compared with forwardRef, the component is a normal function again: it has a name in DevTools, works with generic types in TypeScript, and composes with other wrappers like memo without nesting.

In TypeScript, either declare ref yourself or take the element's props (which already include ref in React 19's types):

MyInput.tsx
import { useRef, type Ref, type ComponentProps } from "react";

type MyInputProps = { label: string; ref?: Ref<HTMLInputElement> };

export function MyInput({ label, ref }: MyInputProps) {
  return <label>{label}<input ref={ref} /></label>;
}

// ComponentProps<"input"> includes ref, so ...rest forwards it automatically
export function TextField({ label, ...rest }: ComponentProps<"input"> & { label: string }) {
  return <label>{label}<input {...rest} /></label>;
}

export function Page() {
  const inputRef = useRef<HTMLInputElement>(null);
  return (
    <>
      <MyInput label="Email" ref={inputRef} />
      <TextField label="Name" ref={inputRef} />
    </>
  );
}
What’s happening
  1. Ref<HTMLInputElement> accepts a ref object or a callback ref, so callers can pass either. It's optional, because most callers won't pass one.
  2. ComponentProps<"input"> is the full set of <input> props from @types/react 19, and it includes ref. Spreading ...rest onto the <input> forwards it along with everything else.
  3. useRef<HTMLInputElement>(null) produces a RefObject<HTMLInputElement | null>, which fits both components. This file compiled with tsc --noEmit against @types/react 19.3 with no errors.
AdvancedMigrating, and the element.ref warning
  • The official codemod converts forwardRef components to ref-as-a-prop: npx codemod@latest react/19/remove-forward-ref (run it on a branch and review the diff; it wasn't run for these notes, and it's known to skip components that use useImperativeHandle).
  • Libraries that must support React 18 and 19 keep forwardRef, since in 18 a function component can't receive ref as a prop.
  • Code that reads element.ref (some old cloneElement helpers) now warns. The test triggered it on 19.3: "Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release." Read element.props.ref instead.

#useImperativeHandle

Sometimes giving the parent the whole DOM node is too much. A design-system input might want to allow focus() and clear(), but not let callers restyle the node or remove it. useImperativeHandle(ref, createHandle, deps?) lets a component decide what the parent's ref receives: instead of the DOM node, ref.current becomes whatever object createHandle returns.

From my notes: while forwardRef simply forwards the ref to a child's DOM element, useImperativeHandle customises what the ref exposes — the parent gets a small interface (methods like focus, scrollToTop, play) instead of full DOM access. A good picture is a TV remote: the parent gets a few buttons, not a screwdriver for the back panel. It's typically used together with forwardRef (React 18) or ref as a prop (React 19).

My notes' example, using forwardRef:

CustomInput.jsx
import React, { useRef, useImperativeHandle, forwardRef } from "react";

// Child component with custom methods exposed to the parent
const CustomInput = forwardRef((props, ref) => {
  const inputRef = useRef();

  // Exposing only focus and clear to the parent, not the entire DOM node
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    clear: () => {
      inputRef.current.value = "";
    },
  }));

  return <input ref={inputRef} type="text" {...props} />;
});

// Parent component
function ParentComponent() {
  const customInputRef = useRef();

  const focusInput = () => {
    customInputRef.current.focus(); // Call custom focus method
  };

  const clearInput = () => {
    customInputRef.current.clear(); // Call custom clear method
  };

  return (
    <div>
      <CustomInput ref={customInputRef} />
      <button onClick={focusInput}>Focus Input</button>
      <button onClick={clearInput}>Clear Input</button>
    </div>
  );
}
// type "hello", click Clear → value ""; click Focus → focused
// Object.keys(customInputRef.current) → ["focus", "clear"]; "value" in it → false
What’s happening
  1. The child keeps its own ref, inputRef, on the real <input>. The parent's ref (ref) is never attached to any element.
  2. useImperativeHandle(ref, () => ({ focus, clear })) tells React: "after commit, set the parent's ref.current to this object". So customInputRef.current is { focus, clear } — the test listed exactly those two keys and confirmed the node's value property isn't reachable.
  3. Each method calls through to the private DOM node. clear sets .value = "" directly, which is fine here because the input is uncontrolled. (On a controlled input you'd call a state setter instead, or React would put the old value back on the next render.)
  4. Clicking Clear emptied the typed hello; clicking Focus focused the input. The parent never touched the DOM directly.
  5. My notes' comment said "exposing only the focus method", but the handle exposes focus and clear — I've fixed the comment. Without a dependency array the handle is re-created after every render, which is harmless here; pass [] (or the values it uses) to avoid it.

The React 19 version, with ref as a prop and a dependency array:

VideoPlayer.jsx
function VideoPlayer({ src, ref }) {
  const videoRef = useRef(null);

  useImperativeHandle(ref, () => ({
    play() { videoRef.current.play(); },
    pause() { videoRef.current.pause(); },
  }), []);

  return <video ref={videoRef} src={src} />;
}

function Page() {
  const playerRef = useRef(null);
  return (
    <>
      <VideoPlayer ref={playerRef} src="/intro.mp4" />
      <button onClick={() => playerRef.current.play()}>Play</button>
      <button onClick={() => playerRef.current.pause()}>Pause</button>
    </>
  );
}
// Play → HTMLMediaElement.play called once; Pause → pause called once
What’s happening
  1. ref arrives as a normal prop and goes straight into useImperativeHandle — no forwardRef wrapper.
  2. The [] dependency array means the handle object is created once. If the methods used props, those props would go in the array so the handle is rebuilt when they change.
  3. The parent's playerRef.current is { play, pause }. jsdom doesn't implement media playback, so the test spied on HTMLMediaElement.prototype.play and pause and saw one call each.
  4. The parent can control playback without being able to change src, read currentTime, or remove the element — the component decides its public API.

#Callback refs and ref cleanup

Added

Besides a ref object, you can pass a function as ref. React calls it with the DOM node when the element is attached. That lets you run code at the moment a node appears — measure it, observe it, register it in a list — without an effect. In React 19 the callback can return a cleanup function, which React calls when the element is detached, exactly like an effect's cleanup. Before 19, React instead called the callback again with null.

Think of a callback ref as an effect attached to one DOM node: setup when the node arrives, cleanup when it leaves. That's often a better fit than useEffect + useRef, because it runs whenever the node itself changes — including nodes that appear later or conditionally.

MeasuredBox.jsx
function MeasuredBox() {
  const [width, setWidth] = useState(0);

  const measureRef = useCallback((node) => {
    const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    observer.observe(node);
    return () => observer.disconnect(); // React 19 ref cleanup
  }, []);

  return <div ref={measureRef}>Width: {width}px</div>;
}
// mount → observe; the observer reports 320 → "Width: 320px"; unmount → disconnect
What’s happening
  1. After React inserts the <div>, it calls measureRef(node). The callback creates a ResizeObserver and starts observing that node.
  2. When the size changes, the observer calls setWidth, and the component re-renders with the new width. The test stubbed ResizeObserver (jsdom doesn't have one), fired a width of 320 and saw Width: 320px.
  3. On unmount, React calls the returned function, which disconnects the observer. The test logged observe, then disconnect.
  4. useCallback(..., []) keeps the same function across renders. That matters: if the function changed every render, React would detach and re-attach (cleanup + setup) on each one — see the next example.

How React decides when to call it:

CallbackTiming.jsx
// Inline: a new function every render
function Inline({ n }) {
  return (
    <p ref={(node) => {
      console.log(`attach <${node.tagName.toLowerCase()}> (render ${n})`);
      return () => console.log(`cleanup (render ${n})`);
    }}>hi</p>
  );
}
// mount, re-render, unmount:
// attach <p> (render 1), cleanup (render 1), attach <p> (render 2), cleanup (render 2)

// Stable: the same function every render
const stableRef = (node) => {
  console.log("attach");
  return () => console.log("cleanup");
};
// mount, re-render, unmount: attach, cleanup

// Pre-19 style: no cleanup returned
<p ref={(node) => console.log(`called with ${node ? node.tagName : node}`)} />;
// mount, unmount: called with P, called with null
What’s happening
  1. Inline callback: on re-render, the ref prop is a different function, so React treats it as a new ref: it runs the old one's cleanup (cleanup (render 1)) and calls the new one (attach … (render 2)) — even though the <p> is the same DOM node.
  2. Stable callback (module-level, or useCallback): same function, so React leaves it alone on re-render. Only mount and unmount call it.
  3. No cleanup returned: React falls back to the old behaviour and calls the callback with null on detach. react.dev says this fallback "will be removed in a future version", so new code should return a cleanup.
  4. In <StrictMode>, React 19 also runs ref callbacks setup → cleanup → setup on mount (the test logged attach, cleanup, attach), the same fire drill it does for effects.

A list of refs — one callback per item, keyed into a Map:

CatList.jsx
function CatList({ cats }) {
  const itemsRef = useRef(new Map());

  function scrollTo(name) {
    itemsRef.current.get(name).scrollIntoView({ behavior: "smooth" });
  }

  return (
    <>
      {cats.map((cat) => (
        <button key={cat} onClick={() => scrollTo(cat)}>Go to {cat}</button>
      ))}
      <ul>
        {cats.map((cat) => (
          <li
            key={cat}
            ref={(node) => {
              itemsRef.current.set(cat, node);
              return () => itemsRef.current.delete(cat);
            }}
          >
            {cat}
          </li>
        ))}
      </ul>
    </>
  );
}
// "Go to Luna" scrolls the Luna <li>; remove Luna → the map's keys are Tom, Milo
What’s happening
  1. You can't call useRef inside a loop (rules of hooks), and you don't know how many items there will be. So one ref holds a Map from name to DOM node.
  2. Each <li> gets a callback ref that adds its node to the map, and returns a cleanup that removes it. Here the inline callback is fine — re-registering on every render is cheap and keeps the map correct.
  3. Clicking "Go to Luna" looks up the Luna node and calls scrollIntoView. The test spied on it and saw it called on the <li> with text Luna.
  4. When Luna is removed from cats, React runs that <li>'s cleanup and the map shrinks to Tom, Milo. Before React 19 you'd have checked for node === null and deleted there.

#Portals

From my notes: portals render a component's children into a different part of the DOM, while keeping them in the same place in the React tree. So a modal, tooltip or dropdown can be physically attached to document.body, but still receives props and context from its React parent, and its events still bubble to its React ancestors.

Why you'd want that, also from my notes: z-index conflicts. In a complex UI, a modal rendered deep inside a component can be trapped by its ancestors — an overflow: hidden container clips it, or a parent with transform, opacity or z-index creates a stacking context that no z-index: 9999 inside it can escape. You end up juggling z-index values across the app. A portal sidesteps the whole problem by putting the modal's DOM at the top level. The mental model: the portal is a teleporter for DOM nodes only — the React family tree stays exactly as you wrote it.

My notes' modal example (it needs a <div id="modal-root"></div> next to the app's root in index.html):

App.jsx
import { useState } from "react";
import { createPortal } from "react-dom";

function Modal({ children }) {
  // Renders the children into a different part of the DOM
  return createPortal(
    <div className="modal">{children}</div>,
    document.getElementById("modal-root") // the target DOM node outside the main app
  );
}

function App() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="app">
      <h1>Hello from the App</h1>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>

      {isOpen && (
        <Modal>
          <h2>Modal Title</h2>
          <p>This is content inside the modal.</p>
          <button onClick={() => setIsOpen(false)}>Close Modal</button>
        </Modal>
      )}
    </div>
  );
}
// Open → the <h2> is inside #modal-root, not inside .app
// Close → #modal-root is empty again
What’s happening
  1. createPortal(children, domNode) (from react-dom) returns something you can render like any JSX. React renders children into domNode instead of into the parent's DOM. My notes used ReactDOM.createPortal with a default import; the named import is the same function.
  2. Clicking Open sets isOpen, so <Modal> renders. In the DOM, the <div class="modal"> ends up in #modal-root. The test checked: .app does not contain the "Modal Title" heading, #modal-root does.
  3. In the React tree, Modal is still a child of App. That's why the Close button's onClick can call setIsOpen(false) — it's an ordinary closure over App's state, passed through children.
  4. Clicking Close unmounts <Modal>, and React removes its DOM from #modal-root (the test saw an empty innerHTML). React manages the portal's nodes fully; you don't clean up anything by hand.

Events and context follow the React tree, not the DOM tree:

Toast.jsx
const Theme = createContext("light");

function Toast() {
  const theme = useContext(Theme); // context reaches through the portal
  return createPortal(
    <button onClick={() => console.log("button clicked")}>Toast ({theme})</button>,
    document.body
  );
}

function Card() {
  return (
    <Theme value="dark">
      <section onClick={() => console.log("section onClick (React parent)")}>
        <Toast />
      </section>
    </Theme>
  );
}
// renders "Toast (dark)"; click it:
// button clicked
// section onClick (React parent)
// …although the button's DOM parent is <body> and <section> doesn't contain it
What’s happening
  1. Toast reads Theme and gets "dark" from the provider above Card's <section>. Context is passed along the React tree, so the portal doesn't break it. (<Theme value> as a provider is React 19 syntax; in 18 it's <Theme.Provider value>.)
  2. The <button> is rendered into document.body. The test confirmed its DOM parent is BODY and that <section> doesn't contain it.
  3. Clicking it runs its own handler, then the <section>'s onClick — the event bubbled through the React tree to a component that isn't its DOM ancestor. React's event system follows component parents.
  4. This is usually what you want (a modal's clicks reach the component that opened it), but it can surprise you: a click inside a modal may trigger an onClick on the page behind it in the component tree. Call e.stopPropagation() inside the portal if that's a problem.
AdvancedPortal details
  • createPortal(children, domNode, key?) — the optional third argument is a key, for when you render a list of portals.
  • The target node must exist before the portal renders. For SSR, the server has no document, so render portals only after mount (for example, behind an isMounted state set in an effect).
  • Portals work with any DOM node, including one owned by a non-React library (e.g. a map's popup element) — a way to render React content inside third-party widgets.
  • Native DOM listeners attached directly to document or the target node see the event according to the DOM tree; only React's synthetic handlers follow the component tree.
Where does the click go?
React
function Page() {
  return (
    <div onClick={() => console.log("page")}>
      <Modal>
        <button onClick={() => console.log("button")}>OK</button>
      </Modal>
    </div>
  );
}
// Modal renders its children with createPortal(…, document.body).
// The user clicks OK.
Show answer

It logs button, then page.

What’s happening
  1. The <button> is in document.body in the DOM, outside the <div>.
  2. React dispatches the click through the component tree: the button's handler first, then each React ancestor with an onClick — Modal (none), then the <div> in Page.
  3. So page logs even though the DOM <div> never received the event. Add e.stopPropagation() to the button's handler to stop at button.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.