Concept
The beginner framing: a ref is a way to "hold onto" a value across renders, often a reference to a real DOM node, without it affecting what's rendered.
The precise mental model: useRef(initialValue) returns the exact same mutable object, { current: initialValue }, on every single render of that component instance. Unlike useState, writing to ref.current does not schedule a re-render at all. This is the fundamental trade-off refs make: they persist across renders like state does, but changing them is invisible to React's rendering system entirely.
function Example() {
const renderCount = useRef(0);
renderCount.current += 1; // mutating .current directly, perfectly fine for a ref
const [count, setCount] = useState(0);
return (
<div>
<p>State count: {count} (updating this re-renders)</p>
<p>Render count: {renderCount.current} (updating this does NOT re-render)</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
);
}Compare this directly to State: useState's setter exists specifically to schedule a re-render; useRef deliberately has no equivalent, because its entire purpose is to hold a value the UI doesn't need to reflect immediately (or at all).
Refs as DOM handles
function TextInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // after commit, .current is the REAL DOM node
}, []);
return <input ref={inputRef} />;
}When you pass a ref to the built-in ref attribute on a DOM element, React sets ref.current to the actual DOM node right after that element is committed, and back to null right before it's removed. This is the sanctioned escape hatch for the handful of things React's declarative model doesn't cover: focusing an input, measuring an element's size, scrolling to a position, or integrating a non-React library that needs a real node.
Passing a ref into your own components
// React 19+: ref is just a regular prop on function components
function FancyInput({ ref, ...props }) {
return <input ref={ref} {...props} />;
}
// Pre-19 (and still valid): forwardRef was required for a component
// to accept a ref from its parent at all
const FancyInputLegacy = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} {...props} />;
});As of React 19, ref can be received like any other prop on a function component, forwardRef is no longer required for the basic case, though it's still valid and still necessary if you're maintaining a library that supports pre-19 React. Either way, if a component needs to expose a customized imperative API instead of the raw DOM node (e.g., a focus() method that also scrolls into view first), useImperativeHandle shapes exactly what the parent's ref sees.
Try It
Predict what happens before checking the solution.
function Timer() {
const secondsRef = useRef(0);
useEffect(() => {
const id = setInterval(() => {
secondsRef.current += 1;
console.log(secondsRef.current);
}, 1000);
return () => clearInterval(id);
}, []);
return <div>Check the console, does this text ever visually update?</div>;
}Solution
The console logs 1, 2, 3, ... correctly every second, secondsRef.current really is being updated. But the rendered text never changes, and the component never re-renders after the initial mount, because writing to ref.current doesn't schedule a re-render. If the goal were to display the running seconds count, this would need useState instead, refs are for values the component needs to track without the UI needing to reflect every change.
Implement It Yourself
Build a minimal useRef using the exact same hook-slot model from Hooks Overview, notice how little code it takes, and how it differs from the useState clone built there:
let hookSlots = [];
let slotIndex = 0;
function useRef(initialValue) {
const currentIndex = slotIndex;
if (hookSlots[currentIndex] === undefined) {
hookSlots[currentIndex] = { current: initialValue }; // create the box ONCE
}
slotIndex++;
return hookSlots[currentIndex]; // return the SAME object every render, never recreated
}The entire difference from useState is one line: there's no setter function that triggers anything, useRef just hands back the same mutable object every time, and mutating .current on it is invisible to any re-render machinery, because there isn't any here to notify.
Under the Hood
A ref's { current } object is a deliberate, sanctioned exception to the immutability discipline established in Props and State, both of those explicitly forbid direct mutation because React needs to detect changes via reference comparison. A ref exists specifically because sometimes you want a value that's exempt from that detection entirely: a plain mutable box, conceptually the same as any object reference in Data Structures that multiple parts of your code hold and mutate in place, just handed to you pre-wired to survive across a component's renders.
Common Mistakes
1. Reading or writing ref.current during the render body
function Component() {
const ref = useRef(0);
ref.current += 1; // ❌ mutating during RENDER, breaks purity
return <div>{ref.current}</div>;
}Render is supposed to be a pure calculation (see Rendering Lifecycle), mutating a ref inside the render body itself is a side effect happening exactly where side effects aren't allowed. Under Strict Mode's double-invoke, this would visibly increment twice per render, and under concurrent rendering, a render that gets thrown away could still have mutated the ref, corrupting it. Refs should be read/written inside effects or event handlers, not the render body.
2. Expecting a ref update to show up in the UI immediately
Covered in Try It above, if a value needs to be reflected on screen, it needs to be state, not a ref. Reaching for a ref specifically to "avoid a re-render" for a value that the UI actually displays just produces a UI that silently doesn't update.
3. Accessing a DOM ref before the component has mounted
function Input() {
const ref = useRef(null);
console.log(ref.current); // ❌ always null here, render hasn't committed yet
return <input ref={ref} />;
}ref.current for a DOM element is null until after that element has actually been committed to the DOM, reading it during render (rather than in an effect, which runs after commit) will always see the stale/null value.
Best Practices
- Reach for a ref when a value shouldn't cause a re-render when it changes, a timer ID, a previous-value cache, a mutable counter, a DOM node handle.
- Reach for state when the value affects what's rendered, if you find yourself wanting to read a ref's value in JSX, that's usually a sign it should be state instead.
- Only read or write
.currentinside effects or event handlers, never in the render body itself. - Use
useImperativeHandlesparingly, only when a parent genuinely needs to call an imperative method (focus(),scrollIntoView()) on a child; prefer passing data down as props and lifting state up for anything that isn't inherently imperative.
Performance Tips
- Refs are the standard escape hatch for values that change frequently but shouldn't drive re-renders, tracking scroll position, mouse coordinates, or a "previous value" for comparison, all without paying for a render on every change.
- A common pattern,
usePrevious, uses a ref specifically to remember the prior render's value for comparison without needing an extra state variable and its accompanying re-render:function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current; // still holds the PREVIOUS value during this render }
