Skip to content

useRef & the DOM

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 happens

That’s the whole difference from state:

useStateuseRef
Survives re-renders✅✅
Changing it re-renders✅ yes❌ no
Read during render✅ (it’s the snapshot)⚠️ avoid (mutable, not reactive)
Use fordata shown in the UIvalues 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);
}

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.

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”:

forwardRef will 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:

src/components/FancyInput.tsx
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.tsx
No errors found

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):

src/components/TrackedList.tsx
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):

src/__tests__/ref-callback-cleanup.test.tsx
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.

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.

ChoiceBenefitCost
ref as a plain prop (React 19)No forwardRef wrapper, less indirection, ref forwarding reads like any other propRequires 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 changesOne more function shape to learn versus the plain ref-object form
useImperativeHandleExposes a minimal, intentional API instead of the raw DOM nodeExtra indirection — only worth it when the raw node is the wrong thing to expose
Reading a ref during renderNoneMakes the component’s output depend on mutable, un-tracked state — breaks the purity the compiler and Strict Mode rely on
  • “forwardRef is legacy but perfectly fine to keep writing.” react.dev now states a deprecation plan for it, not just “no longer necessary” — new components should accept ref as a prop.
  • “A ref callback without a return value just does nothing extra.” It still works today (React falls back to calling it with null on detach), but that fallback is explicitly slated for removal — always return a cleanup function in new code.
  • “Reading ref.current during 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.
  • “useImperativeHandle replaces 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.

When a generated component uses refs, check for these before trusting it:

  • Is ref.current read (or written) during the render body itself, instead of inside an event handler or an effect?
  • Does a component wrap itself in forwardRef when it could just accept ref as 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 useImperativeHandle used 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 render
const value = ref.current; // read during render
return <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 be
accessed outside of render, such as in event handlers or effects. Accessing
a ref value (the `current` property) during render can cause your component
not 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.

What is the key difference between useRef and useState?
What is a good use for a ref?
In React 19, how do you pass a ref through your own component to an inner DOM node?
What does react.dev say about forwardRef's future, beyond calling it "legacy"?
When does the returned function run?
<li
  ref={(node) => {
    if (!node) return;
    list.push(node);
    return () => {
      list.splice(list.indexOf(node), 1);
    };
  }}
/>
What is wrong with this component?
function Clock() {
  const ref = useRef(0);
  ref.current = Date.now();
  const value = ref.current;
  return <span>{value}</span>;
}