Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
useRef returns a plain object { current: T } that persists for the full lifetime of the component without triggering re-renders when mutated. DOM refs are the obvious use case, but the more important pattern is using a ref to store any value you need to read inside async callbacks or effects without creating a stale closure. The pattern is: store the latest value in a ref on every render, then read from the ref (not the state variable) inside the callback. This gives you always-current data without re-creating the callback.
A ref is a stable box React never clears. Assigning .current doesn't trigger a render. Reads from .current always see the latest assignment, making refs the escape hatch for stale closures in async code.
import { useState, useRef, useEffect } from 'react';
// Pattern: always-current value inside a long-lived callback
export function SearchWithAbort() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const queryRef = useRef(query); // mirror of query, no stale closure
// Keep the ref in sync with state on every render
queryRef.current = query;
useEffect(() => {
if (!query) return;
let cancelled = false;
const timer = setTimeout(async () => {
const data = await fakeSearch(queryRef.current); // read ref, not closure
if (!cancelled) setResults(data);
}, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
</div>
);
}
async function fakeSearch(q: string): Promise<string[]> {
return [`Result for "${q}" A`, `Result for "${q}" B`];
}
// DOM ref: focus an input imperatively
export function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="auto-focused" />;
}queryRef.current inside the setTimeout callback with the raw query variable (the state value from the closure). Type quickly and observe that some results show the wrong query. Switch back to queryRef.current and confirm the bug disappears.console.log('render', query) at the top of the component. Mutate queryRef.current = 'direct write' in a button's onClick. Confirm no extra render fires — useRef mutations are invisible to React's render cycle.queryRef.current = query (the assignment on every render) with useEffect(() => { queryRef.current = query }, [query]). This is a subtle bug: the effect runs asynchronously, so queryRef.current can be one render behind. Reproduce the stale result and explain why.inputRef.current.getBoundingClientRect() inside a regular event handler (not an effect). Confirm you get the latest layout — refs give you synchronous access to the DOM at any time, not just inside effects.Use these three in order. Each builds on the one before.
In one paragraph, explain the difference between useRef and useState. Why doesn't mutating `ref.current` trigger a re-render, and when would that be exactly what you want?
Walk me through the 'always-current ref' pattern: why does assigning `ref.current = value` on every render, and then reading `ref.current` inside an async callback, guarantee you see the latest value? Why doesn't the same approach work with a plain variable declared inside the component?
useRef is the foundation of several advanced patterns: storing previous values, measuring DOM dimensions, and implementing debounce/throttle. Pick one and describe its implementation, including the exact render cycle at which the ref is written vs. read.