Mastering useRef in React: A Practical Guide

f you’re diving into React, you’ve likely come across the useRef hook. But what exactly is useRef(), and how can it make your life easier? In this post we’ll cover what useRef is, walk through five real-world use cases, and compare it against the alternatives so its advantages are obvious.
What is useRef() really?
At its core, useRef() returns a mutable object with a .current property that persists across component renders. Unlike state, changing .current does not trigger a re-render. That makes refs useful for holding values you want to survive between renders without touching the UI. Think of useRef as a “box” that can hold any value.
Key points:
The ref object is stable — the same object is returned on every render.
Updating
ref.currentis synchronous and doesn’t cause renders.Common uses: DOM access, storing mutable values (timers, previous values), avoiding stale closures, and exposing imperative methods.
Use case 1 — DOM manipulation
One of the most common uses for useRef is directly accessing a DOM node — for example, focusing an input when a component mounts.
Before (getElementById):
import React, { useEffect, useState } from 'react';
const FocusInput = () => {
const [inputValue, setInputValue] = useState('');
useEffect(() => {
document.getElementById('input').focus();
}, []);
return <input id="input" value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;
};
After (useRef):
import React, { useEffect, useRef, useState } from 'react';
const FocusInput = () => {
const [inputValue, setInputValue] = useState('');
const inputRef = useRef(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} value={inputValue} onChange={(e) => setInputValue(e.target.value)} />;
};
Why it’s better: no global id lookups, no risk of colliding IDs across components, and the reference is scoped to this component. React hands you the node directly.
Use case 2 — Storing previous values
useRef can hold a previous value across renders without triggering an extra render.
Before (using state for the previous value):
import React, { useState, useEffect } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
const [prevCount, setPrevCount] = useState(0);
useEffect(() => {
setPrevCount(count);
}, [count]);
return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
The setPrevCount call schedules a second render every time count changes — wasteful for a value that’s purely informational.
After (useRef via a reusable usePrevious hook):
import React, { useState, useEffect, useRef } from 'react';
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current; // returns the value from the previous render
}
const Counter = () => {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
Why it’s better: the effect writes to the ref after render, so during render usePrevious still returns the prior value — exactly what you want. And there’s no extra render, because updating a ref doesn’t schedule one. This usePrevious pattern is the canonical, reusable version of the idea.
Use case 3 — Storing interval/timeout IDs
Storing a timer ID in state is unnecessary and causes re-renders. A ref is the right home for it.
Before (timer ID in state — not ideal):
import React, { useState, useEffect } from 'react';
const TimerExample = () => {
const [seconds, setSeconds] = useState(0);
const [timerId, setTimerId] = useState(null);
useEffect(() => {
const id = setInterval(() => setSeconds(s => s + 1), 1000);
setTimerId(id);
return () => clearInterval(id);
}, []);
return <div>Seconds: {seconds}</div>;
};
After (timer ID in a ref):
import React, { useState, useEffect, useRef } from 'react';
const TimerExample = () => {
const [seconds, setSeconds] = useState(0);
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(timerRef.current);
}, []);
return <div>Seconds: {seconds}</div>;
};
Why it’s better: the timer ID is an implementation detail the UI never displays, so it doesn’t belong in state. The ref keeps it around for cleanup without any extra renders.
Use case 4 — Avoiding stale closures
When a callback registered once (inside setInterval, an event listener, or a third-party callback) needs the latest state or props, a ref can hold the most recent value without re-creating the callback.
import React, { useState, useEffect, useRef } from 'react';
// Stub for illustration — replace with your real data fetching.
const fetchData = (q) => {
console.log('Fetching results for:', q);
};
const Polling = () => {
const [query, setQuery] = useState('react');
const latestQueryRef = useRef(query);
// Keep the ref in sync with the latest query on every change.
useEffect(() => {
latestQueryRef.current = query;
}, [query]);
// This interval is created once and never re-created.
useEffect(() => {
const id = setInterval(() => {
// Reading the ref gives the latest query, not the stale one
// captured when the interval was first set up.
fetchData(latestQueryRef.current);
}, 5000);
return () => clearInterval(id);
}, []);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
};
Why it matters: the empty dependency array means the interval closure captures query only once — as 'react'. Without the ref, it would poll forever with the stale value. The ref gives the closure a live window into the current state.
Use case 5 — Integrating with third-party libraries or imperative methods
Refs shine at the boundary between React and the imperative world. Two common cases:
- Passing a DOM node to a non-React library
Many charting, mapping, or animation libraries want a raw DOM element to mount into. A ref hands them exactly that.
import React, { useEffect, useRef } from 'react';
import Chart from '/chart.js';
const ChartCanvas = ({ data }) => {
const canvasRef = useRef(null);
useEffect(() => {
const chart = new Chart(canvasRef.current, {
type: 'bar',
data,
});
return () => chart.destroy();
}, [data]);
return <canvas ref={canvasRef} />;
};
- Exposing imperative methods to a parent
With useImperativeHandle, a child can expose specific methods to its parent. As of React 19, ref is passed as a normal prop, so forwardRef is no longer required:
// React 19+: ref is just a prop — no forwardRef needed.
import React, { useImperativeHandle, useRef } from 'react';
const FancyInput = ({ ref, ...props }) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
}));
return <input ref={inputRef} {...props} />;
};
// Parent
// const ref = useRef(null);
// <FancyInput ref={ref} />
// ref.current.focus();
If you’re on React 18 or earlier, wrap the component in forwardRef instead:
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
}));
return <input ref={inputRef} {...props} />;
});
Best practices and pitfalls
Do use refs for:
Accessing DOM nodes.
Storing mutable values that don’t affect rendering (timers, IDs, latest values).
Avoiding stale closures.
Interop with non-React libraries and imperative APIs.
Don’t use refs to:
Replace state when the UI depends on the value. If a change should show up on screen, use state.
Force re-renders — refs intentionally do not trigger them.
Common pitfalls:
Reading
ref.currentbefore it’s assigned — guard with optional chaining (ref.current?.…) or null checks.Using refs for controlled input values — prefer controlled components (state) unless you deliberately want an uncontrolled input.
Reaching for
idselectors for DOM access — refs are cleaner, scoped, and safer.
Conclusion
useRef is a small but powerful hook for keeping mutable values between renders without causing re-renders — the right tool for DOM access, timers and IDs, previous values, avoiding stale closures, and bridging imperative libraries. The rule of thumb: use state for anything the UI displays, and refs for everything mutable that it doesn’t.
Give these patterns a try and watch how they sharpen your components. Happy coding!



