Lesson 6 • Intermediate
React Hooks ⚛️
By the end of this lesson you'll know what hooks are, manage side effects with useEffect and its dependency array, reach into the DOM with useRef, share data with useContext, follow the Rules of Hooks, and package your own logic into a custom hook.
What You'll Learn
- What hooks are and why React added them
- Recap useState — giving a component memory
- Use useEffect for side effects: fetching, timers, subscriptions
- Read the dependency array: run once [], run on change, and cleanup
- Use useRef for DOM access and mutable values that don't re-render
- Read shared data with useContext, follow the Rules of Hooks, and write a custom hook
useState. Note: our editor runs plain JavaScript, so the runnable boxes below simulate hook behaviour to make the logic visible. To see real hooks render, spin up a project with npm create vite@latest.🪝 So, what is a hook?
A hook is a special function whose name starts with use that lets a function component "hook into" React features — like remembering state or running side effects. Before hooks (2019), only clunky class components could do these things; hooks brought them to simple functions.
You already know one hook: useState. This lesson adds the rest of the everyday toolkit. They all share two rules you'll meet at the end: call them at the top level of your component, and only from React functions.
1. Recap: useState gives a component memory
A plain function forgets everything between calls. useState fixes that: it stores a value across renders and gives you a setter to change it. Calling the setter tells React "this value changed — re-render me." The return value is always a pair: [value, setValue]. This box is read-only JSX; read every comment.
// useState gives a component MEMORY. Calling the setter re-renders the
// component with the new value. The pattern is always [value, setValue].
function Counter() {
// useState(0) -> starts at 0. Returns the current value + a setter.
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
// First render: count = 0 -> "Clicked 0 times"
// After 1 click: count = 1 -> "Clicked 1 times" (React re-rendered)
}2. useEffect — running side effects
A side effect is anything that reaches outside React to touch the wider world: fetching data, starting a timer, subscribing to an event, or writing to localStorage. useEffect runs that code after React paints the screen, so it never blocks rendering. The second argument — the dependency array — controls when it runs, and an optional returned function is the cleanup.
// useEffect runs code AFTER React paints the screen — for "side effects"
// like fetching data, timers, or subscriptions. The 2nd argument is the
// DEPENDENCY ARRAY: it tells React WHEN to re-run the effect.
function Timer({ userId }) {
const [seconds, setSeconds] = useState(0);
const [user, setUser] = useState(null);
// 1) Run ONCE after the first render -> empty array []
useEffect(() => {
console.log("Component mounted"); // logs a single time
}, []); // [] = no dependencies
// 2) Run when 'userId' CHANGES -> put it in the array
useEffect(() => {
fetch("/api/users/" + userId)
.then(res => res.json())
.then(setUser); // re-runs only if userId changes
}, [userId]);
// 3) Run AND clean up -> return a cleanup function
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1); // tick every second
}, 1000);
return () => clearInterval(id); // cleanup: stop the old timer
}, []); // before re-run / on unmount
return <p>{seconds}s — {user?.name}</p>;
}🔎 The three dependency-array patterns
useEffect(fn, []) — empty array. Runs once after the first render. Use for one-time setup (fetch on mount, add a global listener).
useEffect(fn, [a, b]) — runs after the first render and any time a or b changes value. Use to re-sync with changing props/state.
useEffect(fn) — no array. Runs after every render. Rarely what you want, and a common cause of infinite loops.
Cleanup: if your effect starts something (a timer, a subscription), return () => { ... } to stop it. React runs cleanup before the next effect and when the component unmounts.
Run this to see how the dependency array decides whether an effect re-runs — it uses the exact same Object.is comparison React does internally.
Worked example: how the dependency array decides
Run it and read the true/false output for each case.
// React's editor here runs plain JavaScript, so we SIMULATE how the
// dependency array decides whether an effect re-runs. The rule:
// React compares each item in the array to its value last render.
// If ANY item changed, the effect runs again.
function shouldEffectRun(prevDeps, nextDeps) {
// Object.is is exactly the comparison React uses internally.
if (prevDeps === null) return true; // first render: always runs
if (prevDeps.length !== nextDeps.length) return true;
for (l
...Your turn. The effect below should reconnect only when roomId changes, but the dependency array is empty. Fill in the blank so it watches the right variable, then run it and check the output.
🎯 Your turn: fix the dependency array
Put the right variable in the deps array so it re-runs on room change.
// 🎯 YOUR TURN — this effect should re-run only when 'roomId' changes,
// but the dependency array is WRONG. Fix the blank, then run it.
function connectionLog(roomId, prevRoomId) {
// We reuse the same comparison React uses for the deps array.
function depsChanged(prev, next) {
if (prev === null) return true;
return !Object.is(prev[0], next[0]);
}
// 👉 The effect connects to a chat room. It must re-run when roomId
// changes. Replace ___ with the value that belongs in t
...3. useRef — DOM refs and mutable values
useRef hands you a small box, { current: ... }, that survives re-renders but, unlike state, does not cause one when you change it. Two everyday jobs: grab a real DOM node (to focus an input or scroll an element) via the ref={...} attribute, and remember a value between renders without re-rendering (like a timer id).
// useRef holds a value in '.current' that SURVIVES re-renders but does
// NOT trigger one when you change it. Two main jobs:
// JOB 1 — Grab a real DOM node (so you can focus it, scroll it, etc.)
function SearchBox() {
const inputRef = useRef(null); // starts as null
useEffect(() => {
inputRef.current.focus(); // focus the <input> on mount
}, []);
return <input ref={inputRef} placeholder="Search..." />;
// The ref={inputRef} wires the DOM node into inputRef.current.
}
// JOB 2 — Remember a value between renders WITHOUT re-rendering.
function Stopwatch() {
const intervalRef = useRef(null); // mutable "instance variable"
function start() {
intervalRef.current = setInterval(tick, 1000); // store the timer id
}
function stop() {
clearInterval(intervalRef.current); // read it back later
}
// Changing intervalRef.current does NOT cause a re-render.
}Run this to feel the key difference: a ref is just an object you can mutate in place, and mutating it never triggers a render — which is exactly why it's safe for a "count the renders" counter that state could never do without looping.
Worked example: a ref is just { current }
Mutate a ref in place — no re-render, no loop.
// Why useRef instead of useState sometimes? Because writing to a ref
// does NOT cause a re-render. We simulate a "render counter" — the
// classic ref use case.
// A ref is just an object: { current: <value> }. That's literally it.
const renderCountRef = { current: 0 };
function simulateRender(label) {
renderCountRef.current = renderCountRef.current + 1; // mutate in place
console.log(label + " -> render #" + renderCountRef.current);
}
simulateRender("mount ");
simulateRender("update")
...4. useContext — shared data without prop drilling
Passing a value down through many layers of components just to reach a deep child is called prop drilling, and it's tedious. useContext lets a deeply nested component read a value provided by an ancestor directly. It's perfect for things many components need: the current theme, the logged-in user, or the chosen language.
// useContext reads a value shared by an ancestor WITHOUT passing props
// down through every level ("prop drilling"). Three steps:
// 1) Create the context (often in its own file)
const ThemeContext = createContext("light");
// 2) Provide a value high up the tree
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
// 3) Read it anywhere below — no props needed
function ThemedButton() {
const theme = useContext(ThemeContext); // "dark"
return <button className={theme}>Save</button>;
}5. The Rules of Hooks
Hooks rely on being called in the same order on every render — that's how React keeps each one matched to its stored value. Break the order and React loses track. Two rules guarantee a stable order:
1. Only call hooks at the top level. Never inside loops, conditions (if), or nested functions. If you need conditional behaviour, put the condition inside the hook, not around it.
// ❌ WRONG — hook hidden behind a condition
if (isLoggedIn) {
const [name, setName] = useState(""); // order changes between renders!
}
// ✅ RIGHT — hook at top level, condition inside
const [name, setName] = useState("");
if (isLoggedIn) { /* use name here */ }2. Only call hooks from React functions — from a component, or from another custom hook (a function whose name starts with use). Never from a plain helper function or an event handler.
Tip: the eslint-plugin-react-hooks lint plugin catches both mistakes (and missing dependencies) automatically.
6. Writing a custom hook
When two components need the same stateful logic, you don't copy-paste it — you extract it into a custom hook: a function named use... that calls other hooks and returns whatever you want. This is React's main way to reuse logic. Run the worked useCounter below; we fake useState with a closure so the logic runs in plain JS.
Worked example: a useCounter custom hook
See how a hook bundles related state and behaviour behind one name.
// A custom hook is just a FUNCTION whose name starts with 'use' and
// which calls other hooks. Here is a 'useCounter' written so it runs in
// plain JS — we fake useState with a tiny closure so you can SEE it work.
function fakeUseState(initial) {
let value = initial; // the stored state
const get = () => value;
const set = (next) => { // accepts a value OR updater fn
value = typeof next === "function" ? next(value) : next;
};
return [ge
...Common Errors (and the fix)
- Infinite re-render loop: calling
setStateinside auseEffectthat has no dependency array (or a dependency that changes every render). Each render triggers the effect, which sets state, which re-renders... Add a correct array, e.g.[]or[id]. - Stale closure (effect uses old values): you left a value out of the dependency array, so the effect keeps using the value from when it was created. The fix React suggests: add every value the effect reads to the array.
- "React Hook useEffect has a missing dependency": the ESLint warning for the above. Add the named variable to the deps array (don't silence the warning blindly).
- "Rendered fewer/more hooks than during the previous render": you called a hook conditionally (inside an
ifor after an earlyreturn). Move every hook to the top level. - Timer or listener never stops: you forgot the cleanup. Return a function from the effect:
return () => clearInterval(id)orremoveEventListener(...). - "Invalid hook call": you called a hook from a normal function, an event handler, or outside a component. Hooks only run inside components or custom
use...hooks.
Pro Tips
- 💡 Use the updater form when new state depends on old:
setCount(c => c + 1)is safer thansetCount(count + 1)inside effects and async code. - 💡 One effect per concern. Don't cram a fetch and a timer into one
useEffect— split them so each has its own deps and cleanup. - 💡 Reach for useRef for anything that shouldn't trigger a render: timer ids, the previous value, or a DOM node.
- 💡 Custom hooks start with
use— that's not a style choice; it's how the lint rules and React know to apply the Rules of Hooks.
📋 Quick Reference
| Hook / pattern | What it does |
|---|---|
| useState(init) | Returns [value, setValue]; setter re-renders |
| useEffect(fn, []) | Run once after first render (on mount) |
| useEffect(fn, [a]) | Run on mount + whenever a changes |
| return () => {} | Cleanup: runs before re-run / on unmount |
| useRef(init) | Mutable .current; no re-render on change |
| useContext(Ctx) | Read a value from a Provider ancestor |
| function useX() | Custom hook: reusable logic, calls other hooks |
Frequently Asked Questions
Q: What's the difference between useState and useRef?
Both persist a value across renders, but changing useState re-renders the component, while changing a useRef does not. Use state for anything shown on screen; use a ref for behind-the-scenes values like a timer id or a DOM node.
Q: When does useEffect actually run?
After React has rendered and painted the screen. With [] it runs once; with dependencies it runs again whenever one of them changes; with no array it runs after every render.
Q: Why did I get an infinite loop?
Almost always an effect that calls setState but has a missing or always-changing dependency array, so it re-runs forever. Give it the correct array — often [] or the specific values it reads.
Q: Do custom hooks have to start with use?
Yes. The use prefix is how React and its lint rules recognise a hook and enforce the Rules of Hooks. useCounter works; getCounter would not be treated as a hook.
Mini-Challenge: build a useToggle hook
No blanks this time — just a brief and a starter outline. Write a useToggle custom hook that flips a boolean. The fakeUseState helper is provided so it runs in our editor; build it, run it, and check your output against the expected lines in the comments.
🎯 Mini-Challenge: useToggle
Return { isOn, toggle, setOn, setOff } from your own custom hook.
// 🎯 MINI-CHALLENGE: write a custom hook called useToggle
//
// A 'useToggle' flips a boolean on and off — perfect for menus, modals,
// and dark mode. Build it using the fakeUseState helper provided.
//
// 1. Accept an 'initial' boolean (default false).
// 2. Keep the current value in fakeUseState.
// 3. Return an object: { isOn, toggle, setOn, setOff }
// - toggle -> flips true<->false (use the updater form: v => !v)
// - setOn -> forces true
// - setOff -> forces false
/
...🎉 Lesson Complete
- ✅ A hook is a
use...function that lets a component use React features - ✅
useStategives memory; its setter re-renders the component - ✅
useEffectruns side effects after render; the dependency array controls when - ✅
[]= run once,[deps]= run on change, andreturna function to clean up - ✅
useRefstores mutable values and DOM nodes without re-rendering - ✅
useContextshares data without prop drilling; follow the Rules of Hooks and bundle logic into custom hooks - ✅ Next lesson: Forms and Controlled Components — capture and validate user input
Sign up for free to track which lessons you've completed and get learning reminders.