7 Things I Wish Someone Told Me About useRef
7 Things I Wish Someone Told Me About useRef#
1. useRef is not "the DOM hook." It is a mutable box that survives renders.
When I first learned hooks, I filed useRef away as "the DOM one." Focus an input, measure a div, scroll to an element. That was my entire mental model and it held up for about a week.
Then I needed to track whether a fetch was still pending after a component unmounted, to avoid calling setState on an unmounted component. I could not use useState for the flag because changing it would trigger a re-render. I could not use a regular variable because it would be re-created every render.
That is when I actually read the docs and realized the DOM stuff is almost incidental. What useRef actually gives you:
const ref = useRef<T>(initialValue);
// ref is { current: T }
// ref.current = newValue does NOT trigger a re-render
// ref.current persists across renders (same object identity)
Compare to useState: setting a new value triggers a re-render. Compare to a regular variable: reset to its initial value on every render. useRef is the thing in between. Persistent but silent.
2. The DOM use case is real, but make it realistic.
Every tutorial shows inputRef.current.focus(). Here is a version closer to what you actually build:
import { useRef, useEffect } from 'react';
interface SearchModalProps {
isOpen: boolean;
onSearch: (query: string) => void;
onClose: () => void;
}
export function SearchModal({ isOpen, onSearch, onClose }: SearchModalProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen && inputRef.current) {
// small delay to let the modal animation finish
const timer = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(timer);
}
}, [isOpen]);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const query = inputRef.current?.value ?? '';
if (query.trim()) {
onSearch(query.trim());
}
}
if (!isOpen) return null;
return (
<div className="search-modal" role="dialog" aria-modal="true">
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
type="search"
placeholder="Search products..."
aria-label="Search"
/>
<button type="submit">Search</button>
</form>
<button onClick={onClose}>Close</button>
</div>
);
}
Type the ref as useRef<HTMLInputElement>(null). The null initial value is the convention for DOM refs because the node does not exist until after the first render.
3. useRef is how you hold timer IDs without triggering re-renders.
This drove me nuts before I understood it. If you store an interval ID in state, every setInterval and clearInterval call triggers a re-render. With useRef, the timer mechanics are invisible to React:
import { useRef, useState, useCallback } from 'react';
export function useCountdown(initialSeconds: number) {
const [timeLeft, setTimeLeft] = useState(initialSeconds);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const start = useCallback(() => {
if (intervalRef.current !== null) return; // already running
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
clearInterval(intervalRef.current!);
intervalRef.current = null;
return 0;
}
return prev - 1;
});
}, 1000);
}, []);
const pause = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
}, []);
const reset = useCallback(() => {
pause();
setTimeLeft(initialSeconds);
}, [pause, initialSeconds]);
return { timeLeft, isRunning, start, pause, reset };
}
The interval ID lives in intervalRef.current. React never knows about it. React does not need to know about it. That is the whole point.
4. You can track previous values with a ref and an effect.
import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}); // no dependency array — runs after every render
return ref.current; // returns value from PREVIOUS render
}
// Usage: animate a price change direction
function PriceDisplay({ price, currency }: { price: number; currency: string }) {
const prevPrice = usePrevious(price);
const direction = prevPrice === undefined
? 'neutral'
: price > prevPrice
? 'up'
: price < prevPrice
? 'down'
: 'neutral';
return (
<span className={`price price--${direction}`}>
{currency}{price.toFixed(2)}
</span>
);
}
The timing is what makes this work: useEffect without a dependency array runs after every render, but ref.current is read during the render. So during render N, you read the value that was stored during render N-1. It felt like a trick when I first saw it. It is not — it is just understanding the render-then-effect cycle.
5. Refs are the escape hatch for stale closures.
This is the pattern Dan Abramov wrote about and it is incredibly useful. When you have a callback that captures stale values because it was created in an earlier render, a ref gives you a way to always read the latest version:
import { useRef, useEffect } from 'react';
// Dan Abramov's useInterval pattern
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
// keep ref current with latest callback
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// Usage: poll an API
function LiveOrderStatus({ orderId }: { orderId: string }) {
const [status, setStatus] = useState<string>('pending');
useInterval(async () => {
// this callback always sees the latest orderId
const response = await fetch(`/api/orders/${orderId}/status`);
const data = await response.json();
setStatus(data.status);
}, 5000);
return <span>Order status: {status}</span>;
}
Without the ref trick, the interval closure would capture the initial version of the callback and always see the orderId from the first render. The ref ensures the interval always calls the latest version.
6. forwardRef lets parents access a child's DOM node.
By default, you cannot attach a ref to a custom component — only to native DOM elements. forwardRef is the bridge:
import { forwardRef, useRef } from 'react';
interface TextInputProps {
label: string;
placeholder?: string;
error?: string;
}
const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ label, placeholder, error }, ref) => {
return (
<div className="field">
<label className="field__label">{label}</label>
<input
ref={ref}
className={`field__input ${error ? 'field__input--error' : ''}`}
placeholder={placeholder}
/>
{error && <p className="field__error">{error}</p>}
</div>
);
}
);
TextInput.displayName = 'TextInput';
// Parent can now focus the input
function CheckoutForm() {
const emailRef = useRef<HTMLInputElement>(null);
function focusEmail() {
emailRef.current?.focus();
}
return (
<form>
<TextInput ref={emailRef} label="Email" placeholder="you@example.com" />
<button type="button" onClick={focusEmail}>
Edit email
</button>
</form>
);
}
And if forwarding the raw DOM ref gives too much access, useImperativeHandle lets you expose only specific capabilities:
import { forwardRef, useRef, useImperativeHandle } from 'react';
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seekTo: (seconds: number) => void;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string; poster?: string }>(
({ src, poster }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play() { videoRef.current?.play(); },
pause() { videoRef.current?.pause(); },
seekTo(seconds: number) {
if (videoRef.current) {
videoRef.current.currentTime = seconds;
}
},
}));
return <video ref={videoRef} src={src} poster={poster} />;
}
);
VideoPlayer.displayName = 'VideoPlayer';
The parent gets play/pause/seekTo. Not the raw video element. Controlled exposure. My inner SAP developer who spent years thinking about access control actually appreciates this pattern.
7. Measuring DOM elements with ResizeObserver + ref is surprisingly clean.
import { useRef, useState, useEffect } from 'react';
interface DOMRect {
width: number;
height: number;
top: number;
left: number;
}
export function useMeasure<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [rect, setRect] = useState<DOMRect>({ width: 0, height: 0, top: 0, left: 0 });
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(([entry]) => {
const { width, height, top, left } = entry.target.getBoundingClientRect();
setRect({ width, height, top, left });
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return [ref, rect] as const;
}
// Usage
function ResponsiveChart({ data }: { data: number[] }) {
const [containerRef, { width }] = useMeasure<HTMLDivElement>();
return (
<div ref={containerRef} style={{ width: '100%' }}>
<svg width={width} height={Math.round(width * 0.5)}>
{/* chart scales with container */}
</svg>
</div>
);
}
useRef is the most underappreciated hook in React. It looks like nothing — just a box with a .current property — but it is the answer to a surprisingly wide range of problems that useState cannot solve.