#React#TypeScript

React Ref Patterns: forwardRef, useImperativeHandle, and Ref Forwarding

Refs are more than "references to the DOM". In component libraries, they shape the API experience. Learn useRef, forwardRef, useImperativeHandle, callback refs, and React 19's ref-as-prop model.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Many developers' understanding of refs stops at “get the DOM and call focus.” Once you start writing a component library, you discover that refs are the real dividing line in component API experience: whether an Input component lets consumers obtain the <input> element or a controlled { focus(), clear() } handle determines how pleasant and maintainable the component is.

This article explains the main ref patterns in one pass: the two faces of useRef, forwarding with forwardRef, controlled exposure with useImperativeHandle, precise control with callback refs, and the simplification brought by React 19's “ref as a regular prop.” Every section includes TSX and gets straight to the point.

1. The Two Faces of useRef

useRef returns an object { current: T } whose identity remains stable throughout the component lifecycle. How stable? You get the same reference on every render. It has two completely different uses, and confusing them is one of the most common beginner mistakes.

Face One: Referencing a DOM Element

Attach a ref to JSX's ref attribute, and React assigns the corresponding DOM node to ref.current during the commit phase:

tsx
function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // 挂载后自动聚焦
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="搜索…" />;
}

Note that the initial value is null, and the type is RefObject<HTMLInputElement | null>. When the component unmounts, React resets current to null. This is important and will matter later when we cover pitfalls.

Face Two: A Mutable Container That Does Not Trigger Re-renders

This is the subtler, and more easily abused, use of useRef: storing an arbitrary value where changing it does not cause the component to re-render.

tsx
function Timer() {
  const timerRef = useRef<number | null>(null);

  useEffect(() => {
    timerRef.current = window.setInterval(() => {
      console.log('tick');
    }, 1000);

    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
    };
  }, []);

  return <p>看控制台</p>;
}

The timer ID does not need to drive the UI, so putting it in state would be pure waste, including an unnecessary re-render. A ref is exactly right: it stays alive, mutable, stable, and unobtrusive.

Rule of thumb: if a value needs to drive rendering, use useState; if it is merely “component side information” (a timer, a previous value, or an instance of a third-party library), use useRef. I have seen code store a request result in a ref and then wonder why the UI did not update. The root cause is always treating a ref as “state that does not trigger updates,” while missing the crucial “does not trigger updates” part.

Common useRef use cases:

  • DOM/component instance references (focus, scrolling, dimension measurement)
  • Handles for timers, subscriptions, and AbortController
  • A “previous value” shared between useEffect calls (previousRef.current)
  • The “last invocation time” in debouncing/throttling

2. forwardRef: Passing a Ref Downward

Passing a ref directly to a custom function component does not work. By default, React does not treat ref as an ordinary prop; it either errors or is silently ignored. From React 16.8 through React 18, the standard approach is to wrap the component in forwardRef:

tsx
type FancyInputProps = {
  label: string;
  defaultValue?: string;
};

const FancyInput = forwardRef<HTMLInputElement, FancyInputProps>(
  function FancyInput({ label, defaultValue }, ref) {
    return (
      <label>
        <span>{label}</span>
        <input ref={ref} defaultValue={defaultValue} />
      </label>
    );
  }
);

forwardRef<RefType, PropsType> has two generic parameters: the first is the type targeted by the ref (HTMLInputElement here), and the second is the props type. The second argument, ref, is the ref supplied by the parent component. We attach it unchanged to the internal <input>, which is exactly what “forwarding” means.

On the consumer side:

tsx
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);

  return (
    <>
      <FancyInput ref={inputRef} label="邮箱" defaultValue="" />
      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
    </>
  );
}

Why pass a ref to a component? Because in many cases, a parent truly needs to operate on the DOM rendered by its child: focus it, clear it, scroll it into view, or read its selection. Without a ref channel, consumers are forced to work around it through controlled props and callbacks, making the API awkward. The golden rule for component libraries is: any custom component that renders a single host component (input, textarea, div, and so on) should support ref forwarding. Otherwise it is a “black box,” and consumers cannot access the most important node inside it.

3. useImperativeHandle: Expose a Controlled API, Not Raw DOM

The problem with forwarding a ref directly is that consumers receive the entire native DOM. They can freely change value, styles, or innerHTML, creating inconsistencies between the component's internal state and external direct manipulation. Worse, if you later replace the internal <input> with <div contenteditable>, every caller that relies on “this is an input” will break.

useImperativeHandle gives you a more restrained approach: expose only the methods you are willing to expose and keep the implementation hidden.

tsx
export type InputHandle = {
  focus: () => void;
  clear: () => void;
  getValue: () => string;
};

type FancyInputProps = { label: string };

const FancyInput = forwardRef<InputHandle, FancyInputProps>(
  function FancyInput({ label }, ref) {
    const inputRef = useRef<HTMLInputElement>(null);

    useImperativeHandle(
      ref,
      () => ({
        focus: () => inputRef.current?.focus(),
        clear: () => {
          if (inputRef.current) inputRef.current.value = '';
          inputRef.current?.focus();
        },
        getValue: () => inputRef.current?.value ?? '',
      }),
      [] // 依赖:通常为空,因为这些方法只依赖稳定的 inputRef
    );

    return (
      <label>
        <span>{label}</span>
        <input ref={inputRef} />
      </label>
    );
  }
);

Three key details:

  1. The ref generic now targets the custom handle type InputHandle, not HTMLInputElement. That is the contract: you promise consumers “this set of methods,” not “this node.”
  2. The third parameter of useImperativeHandle(ref, createHandle, deps) is a dependency array. React recreates the handle only when dependencies change. In most cases, the handle only refers to stable refs internally, so [] is enough. If a handle method closes over props, remember to add those props as dependencies or it will receive stale values.
  3. The handle is attached to the ref only during the commit phase, so reading ref.current during rendering is always null (see Section 7).

When should you use useImperativeHandle, and when should you forward directly? My criterion is whether there is an abstraction boundary:

  • If you are building a thin wrapper (FancyDiv, or a Button that merely adds a className), forwarding the DOM directly is fine. Consumers should treat it as that host element.
  • If you are building a component with its own mental model (Form, Combobox, Modal, VideoPlayer), exposing a controlled API is almost always right. Its internals may be one <input> or ten nested divs; callers should not care. They only need focus(), open(), or play().

This is the core tension in component-library API design: what a ref exposes is what you promise consumers. The narrower the promise, the more freely you can refactor later.

4. Callback Refs: Precise Control with Function Refs

A ref does not have to be a useRef object. It can also be a function. React calls it with the DOM node when the node mounts, and with null when it unmounts.

tsx
function Measure() {
  const [width, setWidth] = useState(0);

  return (
    <div
      ref={(node) => {
        if (node) {
          setWidth(node.getBoundingClientRect().width);
        }
      }}
    >
      宽度:{width}px
    </div>
  );
}

The real value of a function ref is when you need to do something at the exact moment you obtain the node: measure dimensions, register a third-party library (such as a chart or editor), or bind native event listeners.

React 19 Cleanup: Keep Setup and Teardown Together

React 19 gives function refs a long-missing capability: returning a cleanup function. Previously, “mount callback” and “unmount callback” were split across two places, one in the ref function and one in the useEffect return. Now they can be combined:

tsx
function Editor() {
  return (
    <div
      ref={(node) => {
        if (!node) return;
        const editor = createThirdPartyEditor(node);

        // React 19:返回的函数会在卸载或 ref 变化时调用
        return () => {
          editor.destroy();
        };
      }}
    />
  );
}

Putting creation and destruction on adjacent lines is far more readable than splitting them into useEffect, and makes cleanup logic much harder to forget.

Using useState as a “Ref That Can Trigger Rendering”

If you need to obtain a node and have changes to that node trigger rendering, for example to lay out based on its size, a common technique is to give the ref function to a useState setter:

tsx
function ResponsiveBox() {
  const [node, setNode] = useState<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!node) return;
    const observer = new ResizeObserver((entries) => {
      console.log(entries[0].contentRect);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [node]);

  return <div ref={setNode}>观察我</div>;
}

setNode is a stable function and can be used directly as a ref callback. A node change triggers a render and passes the latest node into useEffect. This is much cleaner than manually combining useRef with forceUpdate.

5. React 19: Ref as a Regular Prop

The forwardRef used in the previous four sections is no longer required in React 19. Starting with 19, ref can be passed to function components like any ordinary prop and destructured directly from props:

tsx
type FancyInputProps = {
  label: string;
  ref?: React.Ref<HTMLInputElement>;
};

function FancyInput({ label, ref }: FancyInputProps) {
  return (
    <label>
      <span>{label}</span>
      <input ref={ref} />
    </label>
  );
}

The caller code remains exactly the same:

tsx
const inputRef = useRef<HTMLInputElement>(null);
<FancyInput ref={inputRef} label="邮箱" />;

This change looks small, but it solves several annoying issues:

  1. One less generic layer. The order of forwardRef<Ref, Props>'s two generic parameters was often reversed. Now you declare ref?: React.Ref<T> directly in the props type, just like any other prop.
  2. No separate displayName setup. A component wrapped by forwardRef defaults to "FancyInput" as its displayName, but debugging tools sometimes show it as an anonymous function. Ordinary function components have names naturally.
  3. Spreading props is smoother. {...props} can now pass ref along with everything else, without extracting it separately.

forwardRef remains usable in React 19. It is marked as “no longer recommended,” produces a console notice, but is not breaking, so existing code does not need an urgent migration. For new code, use the prop form directly: less wrapping and less mental overhead.

If you build a component library that must support both React 18 and 19, you still need forwardRef in the short term. Once you drop React 18 support, remove that wrapper decisively.

6. In Practice: An Input That Can Focus and Clear

Let's connect the previous sections. We will build a controlled input that exposes only focus and clear, does not expose raw DOM, and supports React 19's ref-as-prop model through a compatible implementation that runs on both 18 and 19.

tsx
import {
  forwardRef,
  useImperativeHandle,
  useRef,
  type ChangeEvent,
} from 'react';

// 对外契约:调用方能做的就这两件事
export type SmartInputHandle = {
  focus: () => void;
  clear: () => void;
};

type SmartInputProps = {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
};

export const SmartInput = forwardRef<SmartInputHandle, SmartInputProps>(
  function SmartInput({ value, onChange, placeholder, disabled }, ref) {
    const inputRef = useRef<HTMLInputElement>(null);

    useImperativeHandle(
      ref,
      () => ({
        focus: () => inputRef.current?.focus(),
        clear: () => {
          onChange('');
          // 清空后顺手聚焦,符合用户直觉
          inputRef.current?.focus();
        },
      }),
      [onChange] // onChange 来自闭包,必须进依赖
    );

    const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
      onChange(e.target.value);
    };

    return (
      <input
        ref={inputRef}
        value={value}
        onChange={handleChange}
        placeholder={placeholder}
        disabled={disabled}
      />
    );
  }
);

On the consumer side:

tsx
function Demo() {
  const inputRef = useRef<SmartInputHandle>(null);
  const [text, setText] = useState('');

  return (
    <>
      <SmartInput ref={inputRef} value={text} onChange={setText} />

      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
      <button onClick={() => inputRef.current?.clear()}>清空</button>
    </>
  );
}

Several parts of this design are worth discussing:

  • clear() also calls onChange(''). Notice that we define “clear” as business semantics, resetting the controlled value and focusing it, rather than as a DOM operation, input.value = ''. That is the value of a controlled API: one clear() call gives consumers behavior that matches their intuition, not a low-level DOM operation. With a raw DOM ref, callers would have to write three steps themselves, clear the value, synchronize state, and then focus, and could easily miss one.
  • The dependency array is [onChange]. The clear closure uses onChange. If the caller passes a new function on every render, which is common, omitting the dependency produces an old onChange, and state will not update after clearing. This is the most subtle pitfall of useImperativeHandle.
  • Internally, useRef and useImperativeHandle use two refs. One ref targets the DOM, the private inputRef; the other exposes the API, the externally supplied ref. Their responsibilities are separate and do not interfere.

For React 19 only, you can remove forwardRef:

tsx
type SmartInputProps = {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
  ref?: React.Ref<SmartInputHandle>;
};

export function SmartInput({ value, onChange, placeholder, disabled, ref }: SmartInputProps) {
  const inputRef = useRef<HTMLInputElement>(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    clear: () => {
      onChange('');
      inputRef.current?.focus();
    },
  }), [onChange]);

  // …其余 JSX 不变
}

The result is exactly the same, with one less wrapper.

7. Pitfall: Do Not Read or Write ref.current During Render

This is one of the most frequent categories of ref-related bugs, and a common interview topic that filters out many candidates.

Hard rule: ref.current is assigned during the commit phase, not the render phase. Reading or writing ref.current inside the component function body, during render, is unsafe.

A bad example:

tsx
function Bad() {
  const countRef = useRef(0);
  countRef.current += 1; // ❌ 在 render 阶段改 ref

  return <div>渲染次数:{countRef.current}</div>;
}

This code appears to run, but it has two serious problems:

  1. StrictMode invokes it twice. React's development mode intentionally calls render twice to help uncover side effects. countRef.current becomes +2 rather than +1, so the number is wrong.
  2. Renders are not isolated from one another. React can invoke your render without committing it, and concurrent features may discard a render result. Changing a ref during render leaves a side effect from a render that may be discarded, so the ref's value at the next commit differs from what you expect.

A subtler version is reading a ref during render and expecting to receive the node from this mount:

tsx
function AlsoBad() {
  const ref = useRef<HTMLDivElement>(null);

  // ❌ render 阶段 ref.current 还是上一次的值(首次渲染时是 null)
  const width = ref.current?.getBoundingClientRect().width ?? 0;

  return <div ref={ref}>宽度 {width}</div>;
}

On the first render, ref.current is necessarily null, because commit has not happened yet. You read a stale value, and because reading a ref does not trigger rendering, the UI will not update even if you obtain a value.

The correct approach:

  • For side effects, subscriptions, measurements, or ref changes, use useEffect or useLayoutEffect.
  • For a value that drives rendering, use useState, not a ref.
  • For derived values during render, derive them from props/state instead of reading refs.
tsx
function Good() {
  const ref = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);

  useLayoutEffect(() => {
    // ✅ commit 之后,DOM 已就位,测量准确
    if (ref.current) {
      setWidth(ref.current.getBoundingClientRect().width);
    }
  }, []);

  return <div ref={ref}>宽度 {width}</div>;
}

A one-sentence mnemonic: render is a pure function and cannot produce any observable side effects; reading and writing refs are side effects, so they do not belong in render. Internalize this rule and you will avoid half of all ref pitfalls.

Another related small pitfall: do not check whether ref.current exists during render to decide what to render. For example, if (ref.current) return <A/>; return <B/>; is wrong because a ref holds its previous value during render and can make the UI diverge from the actual DOM. If you need to render different content based on “whether it has mounted,” maintain a mounted flag with useState and call setMounted(true) in useEffect.

Summary

  • useRef has two faces: referencing a DOM element or acting as a mutable container that does not trigger re-renders. The criterion is whether it drives the UI. If it does, use useState; if it does not, use useRef.
  • forwardRef solves “passing a ref to a custom component”: every component that renders a single host element should support ref forwarding, or it is a black box. Starting with React 19, ref can be passed directly as a regular prop, so forwardRef is no longer required, though it remains compatible and is no longer recommended.
  • useImperativeHandle defines a component's public contract: expose controlled methods such as focus, clear, and open, rather than raw DOM. The narrower the promise, the more freedom you have to refactor. Remember to put props used in closures into the dependency array.
  • Function refs are appropriate when you need to act at the instant you receive a node: measuring it or registering a third-party library. React 19 supports returning cleanup, so setup and teardown can finally live together.
  • Never read or write ref.current during render: refs are assigned during commit, so reads and writes during render see stale values and are amplified by StrictMode's double invocation. Render is a pure function; ref reads and writes are side effects, and side effects belong in useEffect.

Key takeaway: a ref is not “another state”; it is “a pipe through which a component escapes React's rendering lifecycle.” Whether that pipe should exist and what it exposes are the full craft of component API design.

Related

Related Content