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
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:
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.
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
useEffectcalls (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:
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:
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.
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:
- The ref generic now targets the custom handle type
InputHandle, notHTMLInputElement. That is the contract: you promise consumers “this set of methods,” not “this node.” - 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. - The handle is attached to the ref only during the commit phase, so reading
ref.currentduring rendering is alwaysnull(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 aButtonthat merely adds aclassName), 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 needfocus(),open(), orplay().
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.
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:
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:
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:
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:
const inputRef = useRef<HTMLInputElement>(null);
<FancyInput ref={inputRef} label="邮箱" />;
This change looks small, but it solves several annoying issues:
- One less generic layer. The order of
forwardRef<Ref, Props>'s two generic parameters was often reversed. Now you declareref?: React.Ref<T>directly in the props type, just like any other prop. - No separate
displayNamesetup. A component wrapped byforwardRefdefaults to"FancyInput"as itsdisplayName, but debugging tools sometimes show it as an anonymous function. Ordinary function components have names naturally. - Spreading props is smoother.
{...props}can now passrefalong 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.
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:
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 callsonChange(''). 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: oneclear()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]. Theclearclosure usesonChange. If the caller passes a new function on every render, which is common, omitting the dependency produces an oldonChange, and state will not update after clearing. This is the most subtle pitfall ofuseImperativeHandle. - Internally,
useRefanduseImperativeHandleuse two refs. One ref targets the DOM, the privateinputRef; the other exposes the API, the externally suppliedref. Their responsibilities are separate and do not interfere.
For React 19 only, you can remove forwardRef:
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:
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:
- StrictMode invokes it twice. React's development mode intentionally calls render twice to help uncover side effects.
countRef.currentbecomes +2 rather than +1, so the number is wrong. - 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:
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
useEffectoruseLayoutEffect. - 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.
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
useRefhas 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, useuseState; if it does not, useuseRef.forwardRefsolves “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,refcan be passed directly as a regular prop, soforwardRefis no longer required, though it remains compatible and is no longer recommended.useImperativeHandledefines a component's public contract: expose controlled methods such asfocus,clear, andopen, 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.currentduring 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 inuseEffect.
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
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.