Hooks Deep Dive
Hooks are the primitives that let function components hold state, run side effects, and share logic. useState and useEffect get you started, but real applications live or die on the details: cleaning up subscriptions, keeping dependency arrays honest, avoiding needless recomputation, and packaging repeated logic into custom hooks. This lesson digs into the intermediate hooks you reach for once components grow past a single input and a click handler — the tools that keep an app fast, predictable, and easy to maintain.
- Write correct cleanup functions in useEffect to avoid leaks
- Reason precisely about the dependency array
- Memoize expensive work with useMemo and stabilize functions with useCallback
- Extract reusable stateful logic into custom hooks
- Recognize when memoization helps versus when it just adds noise
useEffect and Cleanup
useEffect runs a side effect after the browser paints. Effects that create ongoing resources — timers, subscriptions, event listeners, network streams — must return a cleanup function. React calls that cleanup before re-running the effect and again when the component unmounts. Without it you leak listeners and end up with stale callbacks firing after the component is gone.
tsximport { useEffect, useState } from "react" function WindowWidth() { const [width, setWidth] = useState(window.innerWidth) useEffect(() => { const onResize = () => setWidth(window.innerWidth) window.addEventListener("resize", onResize) // Cleanup runs before the next effect and on unmount return () => window.removeEventListener("resize", onResize) }, []) // empty array: subscribe once return <p>Window is {width}px wide</p> }
For data fetching, cleanup also lets you ignore results from a request that has been superseded — the classic "race condition on the last keystroke" bug:
tsxuseEffect(() => { let active = true const controller = new AbortController() fetch(`/api/users/${id}`, { signal: controller.signal }) .then((res) => res.json()) .then((data) => { if (active) setUser(data) // ignore if a newer effect already ran }) .catch((err) => { if (err.name !== "AbortError") throw err }) return () => { active = false controller.abort() } }, [id])
Effects run twice in development
The Dependency Array
The dependency array tells React when to re-run an effect. React compares each item with Object.is against the previous render. If nothing changed, the effect is skipped. The rule is simple to state and easy to violate: every reactive value used inside the effect must be listed — props, state, and any function or variable derived from them.
tsxuseEffect(() => { const id = setInterval(() => { console.log(`Room ${roomId}, user ${userId}`) }, 1000) return () => clearInterval(id) }, [roomId, userId]) // both are read inside, so both belong here
Let the linter help
eslint-plugin-react-hooks with the exhaustive-deps rule. It flags missing and unnecessary dependencies. Treat its warnings as bugs — silencing it with a disable comment is almost always the wrong fix.useMemo: Cache Expensive Values
useMemo(fn, deps) remembers the return value of fn and recomputes only when a dependency changes. Use it for genuinely expensive calculations, or to keep a referentially stable object/array so downstream memo components and effect dependencies don't churn.
tsximport { useMemo } from "react" function ProductList({ products, query }) { const filtered = useMemo(() => { return products .filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) .sort((a, b) => a.price - b.price) }, [products, query]) // recompute only when inputs change return ( <ul> {filtered.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> ) }
Do not overuse useMemo
useMemo when profiling shows a real cost or when reference stability actually matters.useCallback: Stabilize Function Identity
Every render creates new function instances. useCallback(fn, deps) returns the same function reference between renders until a dependency changes. It matters when a function is passed to a memo-wrapped child or used as an effect dependency — a fresh reference each render would defeat the memoization or retrigger the effect.
tsximport { useCallback, memo, useState } from "react" const Row = memo(function Row({ id, onSelect }) { console.log("render", id) return <button onClick={() => onSelect(id)}>Select {id}</button> }) function Table({ rows }) { const [selected, setSelected] = useState(null) // Stable reference: Row stays memoized across re-renders const handleSelect = useCallback((id) => setSelected(id), []) return rows.map((r) => <Row key={r.id} id={r.id} onSelect={handleSelect} />) }
useCallback(fn, deps) is exactly useMemo(() => fn, deps) — the same cache, specialized for functions. If the child is not memoized and the function is not an effect dependency, wrapping it adds no benefit.
Custom Hooks: Reusable Logic
A custom hook is any function whose name starts with use and which calls other hooks. It lets you extract stateful logic so multiple components share behavior — not state. Each component that calls the hook gets its own isolated state.
tsximport { useState, useEffect } from "react" function useDebounce<T>(value: T, delay = 300): T { const [debounced, setDebounced] = useState(value) useEffect(() => { const id = setTimeout(() => setDebounced(value), delay) return () => clearTimeout(id) // cancel if value changes before delay }, [value, delay]) return debounced } // Usage: only fires the search after the user stops typing function Search() { const [term, setTerm] = useState("") const debouncedTerm = useDebounce(term, 400) useEffect(() => { if (debouncedTerm) fetch(`/api/search?q=${debouncedTerm}`) }, [debouncedTerm]) return <input value={term} onChange={(e) => setTerm(e.target.value)} /> }
A second common example — reading and syncing to localStorage:
tsxfunction useLocalStorage<T>(key: string, initial: T) { const [value, setValue] = useState<T>(() => { const stored = localStorage.getItem(key) return stored ? (JSON.parse(stored) as T) : initial }) useEffect(() => { localStorage.setItem(key, JSON.stringify(value)) }, [key, value]) return [value, setValue] as const }
Return what callers need
[value, setValue]) when order is intuitive, or a named object ({ data, error, loading }) when there are several values. Use as const on tuples so TypeScript infers exact types instead of a widened union.Common Pitfalls
Rules of Hooks
Next Steps
Next Lesson
React Router