Lesson 3 of 5
Hooks deep dive: useEffect, useRef, useMemo, useCallback, useContext and custom hooks
5 min read
Hooks let function components use React features. Two rules: call hooks only at the top level (not inside loops, conditions or nested functions) and only from React functions (components or custom hooks). React relies on the call order being the same on every render to know which state belongs to which hook.
useEffect: synchronising with the outside world
An effect runs after React commits to the screen. Use it to sync with something outside React: network requests, subscriptions, timers, the document title, third-party widgets.
useEffect(() => {
// setup
return () => {
// cleanup: runs before the next effect and on unmount
};
}, [dependencies]);| Dependency array | When it runs |
|---|---|
| none | after every render |
[] | once after the first render (and cleanup on unmount) |
[a, b] | after the first render and whenever a or b changes |
Fetching data correctly
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setUser(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.statusText))))
.then(setUser)
.catch((e) => { if (e.name !== "AbortError") setError(e); });
return () => controller.abort(); // userId changed or unmounted: drop the old request
}, [userId]);
if (error) return <p>Could not load the profile.</p>;
if (!user) return <Spinner />;
return <h2>{user.name}</h2>;
}The cleanup prevents a race condition: if userId changes quickly, an older slow response can't overwrite the newer one. In real apps, a data library (TanStack Query, SWR) or a framework loader (Next.js server components) handles caching, retries and deduplication for you; say this in interviews to show you know the trade-off.
You might not need an effect
Effects are for external systems. Common misuse:
- Computing derived data (
useEffect(() => setTotal(sum(items)), [items])): just computeconst total = sum(items)during render. - Responding to a user event: put the logic in the event handler, not in an effect that watches state.
- Resetting state when a prop changes: use a
keyinstead.
Strict mode double-run
In development, React 18 Strict Mode mounts, unmounts and re-mounts components, so effects run twice. It's deliberate: it surfaces effects that are missing cleanup. It doesn't happen in production.
useRef: a box that survives renders
useRef returns { current: value }. Changing .current does not cause a re-render. Two uses:
// 1. Access a DOM node
const inputRef = useRef(null);
<input ref={inputRef} />;
inputRef.current.focus();
// 2. Keep a mutable value between renders (timer ids, previous values)
const timer = useRef(null);
function start() { timer.current = setInterval(tick, 1000); }
function stop() { clearInterval(timer.current); }Rule of thumb: if the screen should update when it changes, it's state; if not, it's a ref.
useMemo and useCallback
useMemo(() => compute(a, b), [a, b])caches a value between renders.useCallback(fn, [deps])caches a function (it'suseMemo(() => fn, deps)).
They matter in two situations: an expensive calculation, or keeping a stable reference for a child wrapped in React.memo or for another hook's dependency array. Wrapping everything adds noise and its own cost. Memoize when you have a reason. (The React Compiler can add memoization automatically in newer setups.)
useContext: avoiding prop drilling
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Page />
</ThemeContext.Provider>
);
}
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>{theme}</button>;
}Context is great for values many components read and that change rarely: theme, logged-in user, language. Every consumer re-renders when the value changes, so for fast-changing global state use a store (Zustand, Redux Toolkit) or split contexts.
useReducer: state with rules
When state has several related fields and many ways to change, a reducer keeps the logic in one place and makes it testable:
function reducer(state, action) {
switch (action.type) {
case "added": return [...state, { id: action.id, text: action.text, done: false }];
case "toggled": return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
case "deleted": return state.filter((t) => t.id !== action.id);
default: throw new Error(`Unknown action ${action.type}`);
}
}
const [todos, dispatch] = useReducer(reducer, []);
dispatch({ type: "added", id: crypto.randomUUID(), text: "Practise hooks" });Custom hooks: reuse logic, not UI
A custom hook is a function starting with use that calls other hooks. Each component using it gets its own state.
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return debounced;
}
function Search() {
const [q, setQ] = useState("");
const query = useDebounce(q, 400);
const results = useSearch(query); // only fires 400ms after typing stops
...
}Popular custom hooks to be able to write on a whiteboard: useDebounce, useLocalStorage, useFetch, useOnClickOutside, usePrevious, useWindowSize.
Check yourself
- What runs first: the effect's cleanup or the next effect?
- Why can't hooks be called inside an
if? - When is
useCallbackactually useful? - Write
useLocalStorage(key, initial)from memory.
Sign up free to save your progress.