Hooks and Effects
Before you start
You need: state and events (Article 02).
Time: about 50 minutes, plus the practice. Dependency arrays cause more React bugs than anything else.
Learning objective
Use effects with correct dependencies and cleanup, avoid infinite loops and stale closures, and extract reusable logic into custom hooks.
Topics
- The rules of hooks
useEffectand its dependency array- Cleanup
- Infinite loops
- Stale closures
- When you do not need an effect
useRefuseMemoanduseCallback- Custom hooks
The rules of hooks
Call hooks at the top level of a component or another hook. Never inside a condition, loop or nested function.
// Wrong — the hook order changes between renders
if (isEditing) {
const [name, setName] = useState('');
}
// Right
const [name, setName] = useState('');
if (isEditing) {
// use it
}
React identifies hooks by call order, not by name. A conditional hook shifts every subsequent hook's identity, and state ends up in the wrong variable.
An early return before a hook is the same mistake:
// Wrong — the effect is skipped on some renders
if (!student) {
return <p>Not found</p>;
}
useEffect(() => { /* effect body */ }, [student]);
Put every hook above every conditional return.
Install eslint-plugin-react-hooks and treat its warnings as errors. It catches both rules automatically, and it is the single most valuable lint rule in React.
useEffect
An effect runs after render, for anything outside React's rendering — a subscription, a timer, a DOM measurement, an imperative API.
useEffect(() => {
document.title = `${students.length} students`;
}, [students.length]);
The dependency array controls when it re-runs:
useEffect(() => { /* effect body */ }); // after EVERY render — almost always wrong
useEffect(() => { /* effect body */ }, []); // once, after the first render
useEffect(() => { /* effect body */ }, [publicId]); // when publicId changes
Every value from the component used inside the effect belongs in the array. Omitting one gives a stale closure — the effect keeps using the value from the render that created it.
The lint rule enforces this. Do not silence it with a disable comment; that is how stale-closure bugs are created deliberately.
Cleanup
useEffect(() => {
const id = setInterval(() => refresh(), 30000);
return () => clearInterval(id);
}, [refresh]);
The returned function runs before the effect re-runs and when the component unmounts.
Anything that starts something must stop it:
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
Without cleanup, every mount adds another listener. Navigate in and out ten times and Escape fires the handler ten times — a leak that only shows up as the application getting slower.
StrictMode mounts, unmounts and remounts every component in development specifically to expose missing cleanup. An effect that misbehaves under StrictMode has a real bug.
Cancelling a fetch
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`/api/students/${publicId}`, { signal: controller.signal })
.then(response => {
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
})
.then((data: Student) => {
setStudent(data);
setLoading(false);
})
.catch((err: Error) => {
if (err.name === 'AbortError') {
return; // superseded, not a failure
}
setError('Could not load the student.');
setLoading(false);
});
return () => controller.abort();
}, [publicId]);
Without abort, two problems appear. Navigating quickly from student A to student B leaves A's slower response to arrive last and overwrite B — a visible race. And setting state after unmount is wasted work React warns about.
AbortError is not a failure. Treating it as one shows an error message for a request you cancelled yourself.
Infinite loops
// Loops forever
useEffect(() => {
setStudents([...students, newStudent]);
}, [students]);
The effect sets state, which re-renders, which changes students, which re-runs the effect.
// Also loops — a new object every render
const query = { page: 1, pageSize: 20 };
useEffect(() => {
load(query);
}, [query]);
Objects, arrays and functions declared during render are new references each time, so a dependency array containing one never matches.
Three fixes, in order of preference:
// 1. Depend on primitives
useEffect(() => {
load({ page, pageSize });
}, [page, pageSize]);
// 2. Move it outside the component if it is constant
const DEFAULT_QUERY = { page: 1, pageSize: 20 };
// 3. Memoise, when it genuinely depends on props or state
const query = useMemo(() => ({ page, pageSize, term }), [page, pageSize, term]);
Primitives first. Memoising an object to satisfy a dependency array usually means the effect should have depended on the values instead.
Stale closures
// Wrong — count is captured once and never updates
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
The effect runs once with count at 0. The interval keeps setting it to 1, forever.
// Right — the functional updater always sees the latest value
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
The functional updater removes state from the dependency array, which is often the cleanest fix for an effect that would otherwise re-subscribe on every change.
When you do not need an effect
Most useEffect calls in a beginner codebase should not exist.
// Not needed — derive during render
const [students, setStudents] = useState<Student[]>([]);
const [filtered, setFiltered] = useState<Student[]>([]);
useEffect(() => {
setFiltered(students.filter(s => s.name.includes(term)));
}, [students, term]);
// Correct
const filtered = students.filter(s => s.name.includes(term));
The effect version renders twice per change and can show stale data in between.
// Not needed — do it in the handler
useEffect(() => {
if (submitted) {
save();
}
}, [submitted]);
// Correct
function handleSubmit() {
save();
}
| Do not use an effect for | Do this instead |
|---|---|
| Transforming data for rendering | Calculate during render |
| Responding to a user action | Do it in the event handler |
| Resetting state when a prop changes | Change the component's key |
| Syncing two pieces of state | Derive one from the other |
Resetting on a prop change is worth showing, because the effect version is common and wrong:
// The whole component resets when publicId changes — no effect needed
<StudentForm key={publicId} publicId={publicId} />
Changing a component's key unmounts and remounts it, resetting all its state. Cleaner than an effect that clears six state variables and forgets the seventh.
Effects are for synchronising with something outside React: the DOM, a timer, a subscription, the network, browser storage. Everything else is either a render calculation or an event handler.
useRef
A mutable value that survives renders without causing one.
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="search" />;
// A value that must persist but not trigger a render
const timeoutRef = useRef<number | null>(null);
function handleSearch(term: string) {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => load(term), 300);
}
// Skip an effect on the first render
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
onFiltersChanged();
}, [filters]);
Changing ref.current does not re-render. That is the point, and the trap: a value the UI must display belongs in state, not a ref.
| Use | Choose |
|---|---|
| The UI depends on it | useState |
| It must persist but not render | useRef |
| A DOM element | useRef |
useMemo and useCallback
Both cache between renders. Both are optimisations, and both have a cost.
// Cache an expensive calculation
const topScorers = useMemo(
() => results
.filter(r => !r.isAbsent)
.sort((a, b) => b.marksObtained - a.marksObtained)
.slice(0, 10),
[results]);
// Cache a function reference
const handleSelect = useCallback((publicId: string) => {
setSelected(publicId);
}, []);
Neither is needed for most code. Filtering 200 students on every render is genuinely free; wrapping it in useMemo adds a dependency array to maintain and a comparison to run.
They matter in three cases:
A genuinely expensive calculation — thousands of items, or heavy work per item.
A dependency of an effect, where a new reference each render would re-run it:
const load = useCallback(async () => {
const data = await studentService.search({ term, page });
setStudents(data.items);
}, [term, page]);
useEffect(() => {
load();
}, [load]);
A prop to a memoised child, where a new function reference defeats the memoisation:
const StudentCard = memo(function StudentCard({ student, onSelect }: Props) {
return <article onClick={() => onSelect(student.publicId)}>…</article>;
});
// Without useCallback, onSelect is new every render and memo() achieves nothing
const handleSelect = useCallback((publicId: string) => setSelected(publicId), []);
memo and useCallback must be used together — one without the other does nothing.
Measure before optimising. React DevTools Profiler shows which components re-render and how long they take. Adding useMemo everywhere makes code harder to read and is usually slower.
The React Compiler, when adopted, memoises automatically and removes most of these calls.
Custom hooks
Any function starting with use that calls other hooks. This is how logic is shared between components.
// hooks/useDebounce.ts
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(id);
}, [value, delay]);
return debounced;
}
const [searchTerm, setSearchTerm] = useState('');
const debouncedTerm = useDebounce(searchTerm, 300);
useEffect(() => {
load(debouncedTerm);
}, [debouncedTerm]);
The cleanup clears the previous timer, so only the final value after a pause is committed.
// hooks/useStudents.ts
interface UseStudentsResult {
students: Student[];
loading: boolean;
error: string | null;
reload: () => void;
}
export function useStudents(term: string, page: number): UseStudentsResult {
const [students, setStudents] = useState<Student[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadToken, setReloadToken] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
studentApi.search({ term, page, pageSize: 20 }, controller.signal)
.then(result => {
setStudents(result.items);
setLoading(false);
})
.catch((err: Error) => {
if (err.name === 'AbortError') {
return;
}
setError('Could not load students.');
setLoading(false);
});
return () => controller.abort();
}, [term, page, reloadToken]);
const reload = useCallback(() => setReloadToken(prev => prev + 1), []);
return { students, loading, error, reload };
}
export function StudentList() {
const [term, setTerm] = useState('');
const debouncedTerm = useDebounce(term);
const [page, setPage] = useState(1);
const { students, loading, error, reload } = useStudents(debouncedTerm, page);
if (loading) return <p className="muted">Loading students…</p>;
if (error) return <ErrorMessage message={error} onRetry={reload} />;
if (students.length === 0) return <p className="muted">No students match this search.</p>;
return <StudentTable students={students} />;
}
The component is now presentation only. The hook is where the fetching, cancellation and state live, and it is reusable by any component needing the same data.
reloadToken is the idiomatic way to trigger a re-fetch: bumping a number changes a dependency, which re-runs the effect.
A custom hook is just a function. It has no special mechanism — the use prefix is a convention that the lint rule relies on, and it means the function may call hooks.
Other useful ones:
export function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initial;
} catch {
return initial;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch { /* quota or private mode */ }
}, [key, value]);
return [value, setValue] as const;
}
Every storage access is wrapped — Safari in private mode throws on setItem.
Diagnosing effect bugs
| Symptom | Cause |
|---|---|
| Runs forever | State set inside an effect that depends on that state |
| Runs on every render | An object or function in the dependency array |
| Uses an old value | Missing dependency — stale closure |
| Runs twice in development | StrictMode — expected, and it reveals missing cleanup |
| Handler fires more each visit | No cleanup on a listener or timer |
| Detail page shows the previous record | No AbortController, so a slow response arrives last |
| "changing an uncontrolled input" | Initial state undefined |
memo achieves nothing | A new function prop each render — needs useCallback |
Errors you will hit
| What you see | Cause | Fix |
|---|---|---|
| Infinite loop of requests | Missing dependency array, or a new object each render | Add the array; memoise objects |
| Stale data after the id changes | Dependency array is empty | Include the id |
React Hook useEffect has a missing dependency | The linter is right | Add it, or restructure |
Rendered more hooks than during the previous render | Hook called conditionally | Hooks run unconditionally, at the top |
| State updates after unmount warning | No cleanup on an async effect | Return a cleanup function |
An empty dependency array means "run once". It is right for a one-time setup and wrong for anything that depends on a prop.
Common mistakes
- A hook inside a condition, loop or after an early return
- Silencing the exhaustive-deps lint rule
- No cleanup on a listener, timer or subscription
- No
AbortControlleron a fetch in an effect - Treating
AbortErroras a failure - An object or function in a dependency array
- State set inside an effect that depends on that state
- Reading state in an interval instead of the functional updater
- An effect used to derive data that could be calculated during render
- An effect used to respond to a user action
useReffor a value the UI displaysuseMemoanduseCallbackeverywhere without measuringmemowithoutuseCallbackon its function props- Assuming a
StrictModedouble-run is a bug
Practice
The course exercise is fix state and effect loops.
- Write an effect setting state that depends on that state. Confirm the infinite loop, then fix it.
- Put an object literal in a dependency array. Confirm it runs every render, then depend on primitives.
- Write an interval reading
countdirectly. Confirm it sticks at 1, then fix it with the functional updater. - Add a
keydownlistener with no cleanup. Navigate away and back ten times, press the key, and count the log lines. - Add cleanup and confirm one.
- Fetch a student by id with no
AbortController. Throttle to Slow 3G, click quickly between two students, and confirm the wrong one renders. - Add the controller and confirm the race is gone.
- Show an error message for
AbortError, then exclude it. - Remove a dependency the lint rule wants. Confirm the stale value, then restore it.
- Replace an effect that filters data with a render-time calculation. Compare render counts in the Profiler.
- Reset a form when a prop changes, first with an effect, then with
key. Compare. - Write
useDebounceand use it for a search box. Confirm one request after typing. - Write
useStudentsreturning students, loading, error and reload. Reduce the component to presentation only. - Wrap a child in
memowithoutuseCallbackon its handler. Confirm it still re-renders. AdduseCallback. - Write
useLocalStoragewith try/catch and confirm it survives a refresh.
Exercises 4 and 6 are the two effect bugs that reach production most often.
You can now
- Write effects with correct dependencies and cleanup
- Say what an empty dependency array means
- Recognise the cause of an infinite render loop
- Keep hooks unconditional and at the top
- Write a custom hook
Review questions
- Why must hooks never be called conditionally?
- What is a stale closure, and how does the functional updater avoid it?
- Name three cases where an effect is not the right tool.
- Why does
memodo nothing withoutuseCallbackon function props?
Next: Forms and validation