Skip to main content

Data and Errors

forwardRef, Portals and Lazy Loading

Written by Published

Three escape hatches for problems the normal tree cannot solve: reaching a child's DOM node, rendering outside your parent, and not shipping code until it is needed.

forwardRef: a ref into your own component

ref is not a prop. Putting one on your own component does nothing useful by default, because there is no DOM node for React to attach - the component has to say which node it means.

Focusing a child's input

jsx

const TextField = React.forwardRef(function TextField({ label }, ref) {
  return (
    <label>
      {label}{" "}
      <input ref={ref} placeholder="focus me from the parent" />
    </label>
  )
})

function Form() {
  const inputRef = React.useRef(null)

  return (
    <div>
      <TextField label="Name" ref={inputRef} />
      <div style={{ marginTop: 8 }}>
        <button onClick={() => inputRef.current.focus()}>Focus the field</button>
      </div>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Form />)

The ref arrives as a second argument, separate from props, and the component chooses which element receives it. Without forwardRef, inputRef.current stays null.

In React 19 a function component can accept ref as an ordinary prop, making forwardRef unnecessary for new code. It is still everywhere in existing codebases and libraries, which is why it is worth recognising.

Portals: rendering somewhere else

A portal renders children into a different DOM node while keeping them in the React tree - so state, context and events still flow from the parent, but the markup escapes its container.

This exists for modals, tooltips and dropdowns, which get clipped by a parent's overflow: hidden or trapped under it by z-index. Moving the DOM node out is the only reliable fix.

Escaping an overflow: hidden parent

jsx

function Modal({ children }) {
  // Renders into document.body, not into the clipped box below.
  return ReactDOM.createPortal(
    <div style={{
      position: "fixed", inset: 0, background: "rgba(15,23,42,0.6)",
      display: "grid", placeItems: "center",
    }}>
      <div style={{ background: "#fff", padding: 20, borderRadius: 10 }}>
        {children}
      </div>
    </div>,
    document.body
  )
}

function Demo() {
  const [open, setOpen] = React.useState(false)

  return (
    <div style={{ height: 90, overflow: "hidden", border: "1px dashed #94a3b8", padding: 10 }}>
      <p style={{ margin: 0 }}>This box clips its children.</p>
      <button onClick={() => setOpen(true)}>Open modal</button>

      {open && (
        <Modal>
          <p>Rendered into document.body, so nothing clips it.</p>
          <button onClick={() => setOpen(false)}>Close</button>
        </Modal>
      )}
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Demo />)

Note that ReactDOM.createPortal comes from react-dom, not react - and that the close button still works, because in React's tree the modal is a child of Demo.

Lazy loading with Suspense

React.lazy turns an import into a component that is only downloaded when first rendered. Suspense supplies what to show while that happens.

Code splitting a route

jsx

const Settings = React.lazy(() => import("./Settings"))

function App() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Settings />
    </Suspense>
  )
}

The payoff is a smaller initial bundle: code for a page nobody visits is never sent. It needs a real build step, which is why the example above is not runnable here.

  • forwardRef - pass a ref through your component to a DOM node.
  • createPortal - render into another DOM node, keeping the React tree intact.
  • lazy + Suspense - download a component only when it is needed.
  • All three are escape hatches. Reach for them when the ordinary tree genuinely cannot do the job.