Skip to main content
Published / updated

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
  • useEffect and its dependency array
  • Cleanup
  • Infinite loops
  • Stale closures
  • When you do not need an effect
  • useRef
  • useMemo and useCallback
  • 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 forDo this instead
Transforming data for renderingCalculate during render
Responding to a user actionDo it in the event handler
Resetting state when a prop changesChange the component's key
Syncing two pieces of stateDerive 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.

UseChoose
The UI depends on ituseState
It must persist but not renderuseRef
A DOM elementuseRef

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

SymptomCause
Runs foreverState set inside an effect that depends on that state
Runs on every renderAn object or function in the dependency array
Uses an old valueMissing dependency — stale closure
Runs twice in developmentStrictMode — expected, and it reveals missing cleanup
Handler fires more each visitNo cleanup on a listener or timer
Detail page shows the previous recordNo AbortController, so a slow response arrives last
"changing an uncontrolled input"Initial state undefined
memo achieves nothingA new function prop each render — needs useCallback

Errors you will hit

What you seeCauseFix
Infinite loop of requestsMissing dependency array, or a new object each renderAdd the array; memoise objects
Stale data after the id changesDependency array is emptyInclude the id
React Hook useEffect has a missing dependencyThe linter is rightAdd it, or restructure
Rendered more hooks than during the previous renderHook called conditionallyHooks run unconditionally, at the top
State updates after unmount warningNo cleanup on an async effectReturn 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 AbortController on a fetch in an effect
  • Treating AbortError as 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
  • useRef for a value the UI displays
  • useMemo and useCallback everywhere without measuring
  • memo without useCallback on its function props
  • Assuming a StrictMode double-run is a bug

Practice

The course exercise is fix state and effect loops.

  1. Write an effect setting state that depends on that state. Confirm the infinite loop, then fix it.
  2. Put an object literal in a dependency array. Confirm it runs every render, then depend on primitives.
  3. Write an interval reading count directly. Confirm it sticks at 1, then fix it with the functional updater.
  4. Add a keydown listener with no cleanup. Navigate away and back ten times, press the key, and count the log lines.
  5. Add cleanup and confirm one.
  6. Fetch a student by id with no AbortController. Throttle to Slow 3G, click quickly between two students, and confirm the wrong one renders.
  7. Add the controller and confirm the race is gone.
  8. Show an error message for AbortError, then exclude it.
  9. Remove a dependency the lint rule wants. Confirm the stale value, then restore it.
  10. Replace an effect that filters data with a render-time calculation. Compare render counts in the Profiler.
  11. Reset a form when a prop changes, first with an effect, then with key. Compare.
  12. Write useDebounce and use it for a search box. Confirm one request after typing.
  13. Write useStudents returning students, loading, error and reload. Reduce the component to presentation only.
  14. Wrap a child in memo without useCallback on its handler. Confirm it still re-renders. Add useCallback.
  15. Write useLocalStorage with 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

  1. Why must hooks never be called conditionally?
  2. What is a stale closure, and how does the functional updater avoid it?
  3. Name three cases where an effect is not the right tool.
  4. Why does memo do nothing without useCallback on function props?

Next: Forms and validation