useRef & Forwarding Refs
useRef & Forwarding Refs
useRef serves two distinct purposes that look unrelated at first: accessing DOM elements directly, and storing mutable values that persist between renders without causing re-renders. Both are important tools.
DOM Access
The most common use: getting a direct reference to a DOM node so you can call native DOM methods on it.
import { useRef, useEffect } from "react";
// Auto-focus an input on mount
function SearchModal({ isOpen }: { isOpen: boolean }) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);
return (
<dialog open={isOpen}>
<input ref={inputRef} placeholder="Search..." />
</dialog>
);
}
After the component mounts, inputRef.current holds the actual <input> DOM element. You can call any native DOM method on it: .focus(), .blur(), .scrollIntoView(), .select(), etc.
Storing Values That Don't Trigger Re-renders
useRef returns a mutable object { current: initialValue }. Changing .current does NOT cause a re-render — this is the key difference from useState.
// Tracking the previous value of a prop
function PriceDisplay({ price }: { price: number }) {
const prevPriceRef = useRef<number>(price);
useEffect(() => {
prevPriceRef.current = price; // update after render
});
const prevPrice = prevPriceRef.current;
const changed = prevPrice !== price;
return (
<div>
<span className={changed ? (price > prevPrice ? "text-green-600" : "text-red-600") : ""}>
${price.toFixed(2)}
</span>
{changed && <small>(was ${prevPrice.toFixed(2)})</small>}
</div>
);
}
// Tracking a timer ID
function Countdown() {
const [seconds, setSeconds] = useState(60);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
function start() {
timerRef.current = setInterval(() => {
setSeconds(s => {
if (s <= 1) {
clearInterval(timerRef.current!);
return 0;
}
return s - 1;
});
}, 1000);
}
function stop() {
clearInterval(timerRef.current!);
}
return (
<div>
<p>{seconds}s remaining</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
// Preventing the initial useEffect run (common pattern)
function DataFetcher({ userId }: { userId: string }) {
const [data, setData] = useState(null);
const isMountRef = useRef(false);
useEffect(() => {
if (!isMountRef.current) {
isMountRef.current = true;
return; // skip on first render
}
fetchUser(userId).then(setData);
}, [userId]);
// ...
}
Debouncing with useRef
Store the timer ID in a ref so it persists between renders:
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
// Clear previous timer
if (debounceTimer.current) clearTimeout(debounceTimer.current);
// Set new timer
debounceTimer.current = setTimeout(() => {
onSearch(value);
}, 300);
}
// Clean up on unmount
useEffect(() => {
return () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
};
}, []);
return <input value={query} onChange={handleChange} />;
}
Forwarding Refs
When you build a wrapper component around a native element, consumers might want a ref to the underlying DOM node. forwardRef enables this:
import { forwardRef } from "react";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
// The ref is passed as the second argument
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, error, className = "", ...props },
ref // <-- the forwarded ref
) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input
ref={ref} // attach to the underlying DOM element
className={`w-full border rounded-lg px-3 py-2 ${error ? "border-red-400" : "border-gray-300"} ${className}`}
{...props}
/>
{error && <p className="text-red-600 text-sm mt-1">{error}</p>}
</div>
);
});
// Consumer can now use a ref:
function Form() {
const emailRef = useRef<HTMLInputElement>(null);
function focusEmail() {
emailRef.current?.focus();
}
return (
<form>
<Input ref={emailRef} label="Email" type="email" />
<button type="button" onClick={focusEmail}>Focus email</button>
</form>
);
}
useImperativeHandle
Sometimes you want to expose a custom API through a ref — not the raw DOM node, but specific methods you control:
import { useRef, forwardRef, useImperativeHandle } from "react";
interface VideoPlayerHandle {
play: () => void;
pause: () => void;
seek: (seconds: number) => void;
}
const VideoPlayer = forwardRef<VideoPlayerHandle, { src: string }>(function VideoPlayer(
{ src },
ref
) {
const videoRef = useRef<HTMLVideoElement>(null);
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
seek: (seconds) => {
if (videoRef.current) videoRef.current.currentTime = seconds;
},
}), []);
return <video ref={videoRef} src={src} />;
});
// Consumer gets the custom API, not the raw DOM element
function App() {
const playerRef = useRef<VideoPlayerHandle>(null);
return (
<div>
<VideoPlayer ref={playerRef} src="/video.mp4" />
<button onClick={() => playerRef.current?.play()}>Play</button>
<button onClick={() => playerRef.current?.pause()}>Pause</button>
<button onClick={() => playerRef.current?.seek(30)}>Skip to 0:30</button>
</div>
);
}
Key Distinctions
useRef vs useState:
- Both persist between renders
- useRef changes do NOT trigger re-renders
- useState changes DO trigger re-renders
- Use useRef for values that are implementation details
- Use useState for values that should be reflected in the UI
useRef vs a variable in the component body:
- Variables in the component body are recreated on every render
- useRef persists the same object across renders
Next lesson: Custom hooks — extracting and reusing component logic.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises