skip to content

In React 19, a `withTooltip(Button)` wrapper is rendered with a `ref` so the caller can focus the underlying `<button>`. Does the ref reach it, and how did this differ in React 18?

level: middleimportance: should knowfreq 40%

answer

  1. it used to be dropped at the wrapper
  2. ref is a prop now
  3. spread the rest and it rides along
  4. forwardRef was the old escape hatch
  5. key is still not a prop

basics

~20 s

In React 19 the ref reaches the inner component, because ref is an ordinary prop on function components, so a wrapper that spreads its props forwards it automatically. In React 18 and earlier the ref was dropped unless the wrapper was built with forwardRef.

solid answer

~50 s

In React 19 it works, provided the wrapper actually forwards what it received. `ref` is now a regular prop on function components, so `function WithTooltip(props) { return <Button {...props} />; }` passes the ref straight through with everything else, and `Button` can accept it as `({ ref, ...rest })` and put it on its `<button>`. Before React 19, `ref` was extracted from props and handled specially, so a wrapper never saw it: the caller got a development warning about giving refs to function components, and the fix was to build the wrapper with `forwardRef((props, ref) => <Button {...props} ref={ref} />)`. That is why almost every pre-19 HOC in library code is wrapped in `forwardRef`. It still works in React 19 and is still what you need when the inner component is a class, but it is no longer required for the ordinary case. The trap that survives is a wrapper that destructures named props and forgets to pass `ref` on.

code

jsx · 24 lines
jsx
import { useRef, useEffect } from 'react';

function Button({ ref, ...rest }) {
  return <button ref={ref} {...rest} />;
}

function withTooltip(Wrapped) {
  function WithTooltip({ tooltip, ...rest }) {
    // rest still carries ref in React 19
    return <Wrapped title={tooltip} {...rest} />;
  }
  WithTooltip.displayName = `withTooltip(${Wrapped.name || 'Component'})`;
  return WithTooltip;
}

const TooltipButton = withTooltip(Button);

export function Toolbar() {
  const btn = useRef(null);
  useEffect(() => {
    btn.current.focus();
  }, []);
  return <TooltipButton ref={btn} tooltip="Save">Save</TooltipButton>;
}

go deeper

for a junior

Know that a ref has to be passed down explicitly through each component between the caller and the DOM node, and that spreading the remaining props is what carries it in React 19.

for a middle

Explain why React 18 dropped it — ref was extracted from props and handled specially — and that forwardRef was the workaround, whereas React 19 delivers it as an ordinary prop.

for a senior

Demonstrate how you would diagnose a null ref through several layers of wrappers, and decide whether to modernise the chain or leave forwardRef in place for a class-based target.

for a principal

Treat imperative handles as part of the public contract of a wrapped component: exposing a raw DOM node commits you to that element, which is a compatibility decision worth making deliberately across a shared component library.

## The setup A caller wants imperative access to a DOM node — focus it, scroll it into view, start a media element — so it passes a ref down. Between the caller and the node sits a wrapper component produced by a higher-order component. The question is whether the ref survives the trip. ## Why it used to fail Up to and including React 18, `ref` was not a prop. Along with `key`, React pulled it off the element's props during element creation and stored it separately, because `ref` had a fixed meaning: for a host element it means the DOM node, for a class component it means the instance. Function components had no instance, so passing a ref to one was meaningless and React warned in development: function components cannot be given refs. The consequence for wrappers was that `<Wrapped {...props} />` could not forward a ref, because the ref was never in `props` to begin with. The escape hatch was `forwardRef`, which produces a component whose render function receives the ref as an explicit second argument: ```jsx // React 18 style — still valid in 19 const WithTooltip = forwardRef(function WithTooltip(props, ref) { return <Button {...props} ref={ref} />; }); ``` This is why virtually every pre-19 HOC in a serious library is written with `forwardRef` on the outside: without it, any consumer that needed the DOM node was blocked by the wrapper. ## What React 19 changed React 19 makes `ref` a regular prop for function components. Writing `<WithTooltip ref={r} />` now delivers `r` in `props.ref`, and the component can pull it out like anything else: ```jsx function Button({ ref, ...rest }) { return <button ref={ref} {...rest} />; } function WithTooltip(props) { return <Button {...props} />; // ref rides along in the spread } ``` Two practical effects. First, a HOC written the obvious way — receive props, spread props — now forwards refs for free, and the whole category of "the ref stops at the wrapper" bugs mostly disappears. Second, `forwardRef` becomes optional for the common case. It still exists and still behaves as before; React has stated it intends to deprecate it in a future version, so new code should prefer the prop. Note what did *not* change: `key` is still special and is still not a prop, and class components still receive the instance through the ref rather than through props — so a wrapper whose inner component is a class continues to hand the ref to the class element as an attribute, and gets the instance, not a DOM node. ## The trap that survives Ref-as-a-prop only helps if the wrapper actually forwards it. A wrapper that names the props it wants and drops the remainder still swallows the ref: ```jsx function WithTooltip({ label, onClick }) { return <Button label={label} onClick={onClick} />; // ref is gone } ``` Nothing throws. The caller's ref object simply stays `null`, and the symptom shows up far away — a focus call that silently does nothing, a measurement that reads zero. The habit that prevents it is the same one that prevents swallowed props generally: destructure what you need, collect the rest, spread the rest. The second surviving trap is renaming. Some wrappers deliberately expose a different name — `innerRef` was a common workaround in the forwardRef era, invented precisely because `ref` could not travel. If you are modernising such a wrapper, remove the alias rather than keeping both, because two ways to pass the same thing is exactly the kind of shim that outlives everyone who understood it. ## How to verify it The check is cheap and worth doing whenever you touch a wrapper: render it with a ref, and assert in an effect that the ref's `current` is the node you expected — a DOM element for a host target, a class instance for a class target. If it is `null`, walk down the wrappers one at a time and find the one that stopped forwarding. A ref that arrives as `null` is almost always a forwarding gap, not a timing problem, when the assertion runs after the commit rather than during render.

  • If ref is now a regular prop, why does `forwardRef` still exist at all?
    For compatibility with the enormous amount of code written against React 18 and earlier, and because it is still the way to reach a class component's instance through a function wrapper. React has said it plans to deprecate `forwardRef` in a future version, so new components should take `ref` as a prop and existing ones can be converted when they are next touched.
  • A wrapper forwards the ref correctly, yet the caller's `ref.current` is null inside its own render. Why?
    Refs are attached during the commit, after render returns, so reading `current` while rendering sees the value from before this update — usually `null` on the first pass. Read it in an effect or an event handler instead. This is timing, not forwarding: the same code works once the assertion moves out of the render body.

saying these in an interview costs you the question

  • Says forwardRef is still mandatory in React 19
  • Assumes any wrapper forwards refs automatically, even when it drops props
  • Thinks key became a regular prop as well
  • Expects a ref on a wrapper to yield the wrapper's own instance
  • Reads ref.current during render and calls the forwarding broken

context