useRef & the DOM
A ref is a box that survives renders
Section titled “A ref is a box that survives renders”useRef gives you a mutable container — an object { current: value } — that React keeps across renders but does not watch. Changing ref.current does not trigger a re-render.
const ref = useRef(0);ref.current += 1; // mutate freely — NO re-render happensThat’s the whole difference from state:
useState | useRef | |
|---|---|---|
| Survives re-renders | ✅ | ✅ |
| Changing it re-renders | ✅ yes | ❌ no |
| Read during render | ✅ (it’s the snapshot) | ⚠️ avoid (mutable, not reactive) |
| Use for | data shown in the UI | values you need to remember but not render |
Use a ref for things the UI doesn’t directly display: a timer id, the previous value of something, a mutable counter, a scroll position, or a reference to a DOM node.
// Classic use: hold a timer id so you can clear it later.const intervalRef = useRef(null);function start() { intervalRef.current = setInterval(tick, 1000);}function stop() { clearInterval(intervalRef.current);}Refs for DOM access
Section titled “Refs for DOM access”The most common use is grabbing a DOM node — to focus an input, measure an element, or call an imperative browser API. Attach the ref to a host element via the ref attribute; after commit, ref.current points at the real DOM node.
function SearchBox() { const inputRef = useRef(null); return ( <> <input ref={inputRef} /> <button onClick={() => inputRef.current.focus()}>Focus</button> </> );}This is an escape hatch — you’re stepping outside the declarative model to talk to the DOM directly. Use it for focus, scrolling, measurements, and integrating non-React widgets; don’t use it to do things state can do.
React 19: ref is just a prop
Section titled “React 19: ref is just a prop”Previously, passing a ref through your own component to a DOM node inside it required wrapping the component in forwardRef. In React 19, ref is a regular prop — you can accept it and pass it down like any other prop, and forwardRef is no longer needed for new code.
// ✅ React 19: accept ref as a normal prop — no forwardRef.function MyInput({ placeholder, ref }) { return <input placeholder={placeholder} ref={ref} />;}
// Parent:const ref = useRef(null);<MyInput ref={ref} placeholder="Search" />;The old form still works, but react.dev is now explicit about its timeline, not just “legacy”:
forwardRefwill be deprecated in a future release. — react.dev/reference/react/forwardRef
That’s stronger than “not needed anymore” — it’s a stated deprecation plan. For new components, accept ref as a prop. If you’re maintaining code that still uses forwardRef, it keeps working; there’s no forcing function to migrate it today, but new components shouldn’t add more of it.
For the rarer case where you want to expose a custom imperative API (not the raw DOM node) to a parent, useImperativeHandle lets you define exactly what the ref exposes — and in React 19 it composes directly with ref-as-a-prop, no forwardRef wrapper required:
import { useImperativeHandle, useRef } from 'react';
type FancyInputHandle = { focus: () => void; clear: () => void;};
function FancyInput({ ref }: { ref?: React.Ref<FancyInputHandle> }) { const inputRef = useRef<HTMLInputElement>(null); useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { if (inputRef.current) inputRef.current.value = ''; }, })); return <input ref={inputRef} />;}
export default FancyInput;Type-checks clean under tsc --strict in a React 19.3 project — no forwardRef, no separate generic-parameter dance:
$ tsc --strict --jsx react-jsx src/components/FancyInput.tsxNo errors foundRef callback cleanup functions (React 19)
Section titled “Ref callback cleanup functions (React 19)”A ref can also be a callback instead of a ref object — React calls it with the DOM node when attached. Since React 19, that callback may return a cleanup function, which React calls when the ref detaches (or right before running a new callback instance):
import { useRef } from 'react';
function TrackedList({ items }: { items: string[] }) { const itemsRef = useRef<HTMLLIElement[]>([]); return ( <ul> {items.map((item) => ( <li key={item} ref={(node) => { if (!node) return; itemsRef.current.push(node); // React 19: return a cleanup function from a ref callback. return () => { itemsRef.current = itemsRef.current.filter((n) => n !== node); }; }} > {item} </li> ))} </ul> );}
export default TrackedList;Before this cleanup form existed, the only way to know a node was detached was that React called the callback again with null — easy to forget, and impossible to pair cleanly with per-node setup. Proven below: cleanup runs both on unmount and whenever a new callback instance replaces the old one (React tears down the previous ref before attaching the next):
import { useRef, useState } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function List({ log }: { log: string[] }) { const itemsRef = useRef<HTMLLIElement[]>([]); const [items] = useState(['a', 'b']); return ( <ul> {items.map((item) => ( <li key={item} ref={(node) => { if (!node) return; itemsRef.current.push(node); log.push(`attach ${item}`); // React 19: a ref callback may return a cleanup function. return () => { itemsRef.current = itemsRef.current.filter((n) => n !== node); log.push(`detach ${item}`); }; }} > {item} </li> ))} </ul> );}
test('a ref callback cleanup function runs on unmount (React 19)', () => { const log: string[] = []; const { unmount } = render(<List log={log} />); expect(log).toEqual(['attach a', 'attach b']); log.length = 0; unmount(); expect(log).toEqual(['detach a', 'detach b']);});
test('a ref callback cleanup runs when a NEW callback instance is passed on re-render', () => { const log: string[] = []; function Box({ tag, log }: { tag: string; log: string[] }) { return ( <div ref={() => { log.push(`attach:${tag}`); return () => { log.push(`detach:${tag}`); }; }} /> ); } const { rerender } = render(<Box tag="v1" log={log} />); expect(log).toEqual(['attach:v1']); log.length = 0; // A brand new inline function is passed -> React tears down the old ref // (runs its cleanup) before attaching the new one. rerender(<Box tag="v2" log={log} />); expect(log).toEqual(['detach:v1', 'attach:v2']);});✓ a ref callback cleanup function runs on unmount (React 19)✓ a ref callback cleanup runs when a NEW callback instance is passed on re-render
Test Files 1 passed (1) Tests 2 passed (2)react.dev’s own caveat backs the second test directly: “When you pass a different ref callback, React will call the previous callback’s cleanup function if provided.” — without a cleanup function, React instead falls back to calling the old callback with null, a behavior the docs say will be removed in a future version. Write the cleanup form for any ref callback you author today.
Mental Model
Section titled “Mental Model”A ref is React’s answer to “I need a value that outlives this render, but I don’t want React to schedule work because of it.” State is for values the render function depends on; a ref is for values that live alongside the render function without participating in it. That is also why reading or writing ref.current during render (not inside an event handler or an effect) is a real bug, not a style nitpick — render is supposed to be a pure computation of “props/state → UI,” and refs are mutable, unordered escape hatches from exactly that computation.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
ref as a plain prop (React 19) | No forwardRef wrapper, less indirection, ref forwarding reads like any other prop | Requires React 19+; code targeting older React still needs forwardRef |
| Ref callback with cleanup (React 19) | Correct per-node attach/detach pairing, even when the callback identity changes | One more function shape to learn versus the plain ref-object form |
useImperativeHandle | Exposes a minimal, intentional API instead of the raw DOM node | Extra indirection — only worth it when the raw node is the wrong thing to expose |
| Reading a ref during render | None | Makes the component’s output depend on mutable, un-tracked state — breaks the purity the compiler and Strict Mode rely on |
Common Misconceptions
Section titled “Common Misconceptions”- “
forwardRefis legacy but perfectly fine to keep writing.” react.dev now states a deprecation plan for it, not just “no longer necessary” — new components should acceptrefas a prop. - “A ref callback without a return value just does nothing extra.” It still works today (React falls back to calling it with
nullon detach), but that fallback is explicitly slated for removal — always return a cleanup function in new code. - “Reading
ref.currentduring render is just as safe as reading state.” It isn’t: refs are mutable and unordered with respect to rendering, so a value read during render can be stale, inconsistent under Strict Mode’s double-render, or simply wrong the next time the same render is replayed. - “
useImperativeHandlereplaces normal props.” It’s for the rare case of exposing an imperative API (focus(),scrollIntoView()) — most component communication should still go through props and state, not a ref handle.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated component uses refs, check for these before trusting it:
- Is
ref.currentread (or written) during the render body itself, instead of inside an event handler or an effect? - Does a component wrap itself in
forwardRefwhen it could just acceptrefas a prop (React 19+ target)? - Does a ref callback that needs to detach cleanly (adding to a list, subscribing to an observer) return a cleanup function, or does it rely on the deprecated null-callback fallback?
- Is
useImperativeHandleused to expose a whole DOM node (defeating its purpose) instead of a small, intentional set of methods? - Is a ref being used to store a value that’s actually displayed in the UI — a sign it should be state instead?
🐛 Spot the bug
function Clock() {const ref = useRef(0);ref.current = Date.now(); // written during renderconst value = ref.current; // read during renderreturn <span>{value}</span>;}Show the answer
Both the write and the read happen in the render body itself, not in an event handler or an effect. React’s own lint rule for this (react-hooks/refs, part of [email protected], the compiler-aware successor to the older react-hooks rule set) rejects it outright when run against this exact pattern:
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only beaccessed outside of render, such as in event handlers or effects. Accessinga ref value (the `current` property) during render can cause your componentnot to update as expected (https://react.dev/reference/react/useRef).The fix is to move both the write and the read into an effect (or an event handler) and store anything the UI needs to display in state, not a ref — a ref’s mutations are invisible to React’s scheduler, so a component that reads one during render can silently render stale or inconsistent values, especially under Strict Mode’s extra render pass or the React Compiler’s memoization, both of which assume render is a pure function of props and state.