Session 11 — Custom Hooks
As components grow, you often write the same stateful logic in multiple places — a loading state here, a localStorage sync there, a debounced input somewhere else. Custom hooks let you extract that logic into a reusable, named function.
A custom hook is a JavaScript function whose name starts with use and that can call other React hooks inside it. No special API, no new syntax — just a naming convention that unlocks React's rules-of-hooks enforcement for your own functions.
Instead of scattering useState and useEffect across components, the hook owns all the logic. The component just receives the result.
Calling the same custom hook in two different components gives each component its own independent state. Hooks aren't singletons — every call is a fresh instance.
Extract complex behavior into a hook and you can test that logic directly — without mounting a full component tree around it.
You already know how to extract a repeated block of code into a helper function. Custom hooks are exactly that — but for stateful logic that involves hooks. The use prefix signals to React (and to you) that this function follows the rules of hooks and may contain useState, useEffect, and others inside it.
Custom hooks obey the same rules as built-in hooks. React's eslint-plugin-react-hooks only recognizes functions starting with use as hooks — break the naming rule and the linter can't protect you from calling hooks conditionally.
The use prefix is how React and the ESLint plugin identify a function as a hook. Without it, calling other hooks inside would silently break rules without any warning.
The same rule applies inside your hook: no useState or useEffect inside an if block, a loop, or after an early return. Hook call order must be stable across renders.
You can return a single value, an array (like useState), a plain object, or nothing at all. Pick whatever shape is most natural for the hook's purpose.
If your function returns JSX, it's a component, not a hook. Custom hooks return data and functions — they have no UI of their own.
// ✓ Valid custom hook names function useWindowSize() { … } function useLocalStorage(key, initial) { … } function useDebounce(value, delay) { … } function useFetch(url) { … } function useOnClickOutside(ref, handler) { … } // ✗ Not hooks — linter won't enforce rules-of-hooks on these function getWindowSize() { … } // doesn't start with "use" function windowSizeHook() { … } // suffix "Hook" doesn't count // ✗ Not a hook — returns JSX, so it's a component function useButton() { return <button>Click me</button>; // → rename to Button, it's a component }
The convention is useThingYouAreTracking. Describe what the hook provides — useMousePosition, useAuth, useFormField. Favor nouns over verbs: useFetch beats useGetData because "fetch" already implies the action. If you struggle to name it, the hook might be doing too many unrelated things.
The classic entry point for custom hooks is a counter. It's deliberately simple — once you see how the extraction works, you'll recognize the same pattern in every hook you write or consume from a library.
Imagine LikeButton and CartItem both need the same increment / decrement / reset logic. Without extraction, that logic lives in both components as identical code:
// LikeButton.jsx function LikeButton() { const [count, setCount] = useState(0); const inc = () => setCount(c => c + 1); const dec = () => setCount(c => c - 1); const reset = () => setCount(0); return (…); } // CartItem.jsx — identical, copy-pasted function CartItem() { const [count, setCount] = useState(0); const inc = () => setCount(c => c + 1); const dec = () => setCount(c => c - 1); const reset = () => setCount(0); return (…); }
// hooks/useCounter.js function useCounter(initial = 0) { const [count, setCount] = useState(initial); const inc = () => setCount(c => c + 1); const dec = () => setCount(c => c - 1); const reset = () => setCount(initial); return { count, inc, dec, reset }; } // LikeButton.jsx — clean function LikeButton() { const { count, inc, dec } = useCounter(); return (…); } // CartItem.jsx — own independent state function CartItem() { const { count, inc, reset } = useCounter(1); return (…); }
Both LikeButton and CartItem call useCounter(), but each gets its own completely independent counter state. Hooks don't share state between callers — every call is a fresh, isolated instance. If LikeButton increments to 5, CartItem stays at 1.
import { useState } from 'react'; export function useCounter(initial = 0, { min, max } = {}) { const [count, setCount] = useState(initial); const clamp = n => { if (min !== undefined && n < min) return min; if (max !== undefined && n > max) return max; return n; }; const inc = (step = 1) => setCount(c => clamp(c + step)); const dec = (step = 1) => setCount(c => clamp(c - step)); const reset = () => setCount(initial); const set = (value) => setCount(clamp(value)); return { count, inc, dec, reset, set }; } // Usage examples: // const { count, inc } = useCounter(); // 0, no bounds // const { count, inc, dec } = useCounter(5, { min: 0, max: 10 }); // clamped // const { count, inc } = useCounter(1); // starts at 1
Built-in hooks like useState return an array so you can name items freely. Custom hooks that return multiple values are easier to consume as named objects — const { count, inc } = useCounter() is far clearer than positional array destructuring when there are 3+ items. Arrays work best when you always need all values in a fixed order (like useState's two-element tuple).
useState is ephemeral — it resets every time the page refreshes. useLocalStorage is a drop-in replacement that also writes every state update to localStorage and reads from it on the first render. From the component's perspective the API is identical to useState — just persistent.
import { useState } from 'react'; export function useLocalStorage(key, initialValue) { // Step 1 — initialize: read from storage or fall back to initialValue const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); // JSON.parse handles numbers, booleans, arrays, objects — not just strings return item !== null ? JSON.parse(item) : initialValue; } catch { return initialValue; // storage unavailable (private browsing, quota full, etc.) } }); // Step 2 — setValue: write to storage AND update React state const setValue = (value) => { try { // Support functional updates: setValue(prev => [...prev, newItem]) const toStore = value instanceof Function ? value(storedValue) : value; window.localStorage.setItem(key, JSON.stringify(toStore)); setStoredValue(toStore); } catch { // Quota exceeded or private mode — silently fall back to in-memory state } }; return [storedValue, setValue]; // ← same shape as useState's return value }
import { useLocalStorage } from './hooks/useLocalStorage'; function Settings() { // Exactly like useState — but values survive page reloads const [theme, setTheme] = useLocalStorage('settings.theme', 'dark'); const [language, setLanguage] = useLocalStorage('settings.lang', 'en'); return ( <form> <select value={theme} onChange={e => setTheme(e.target.value)}> <option>dark</option> <option>light</option> </select> </form> ); // Picking "light" writes to localStorage immediately. // Next page load: hook reads it back — the user's choice is remembered. }
localStorage stores everything as a plain string. If you store a number and read it back raw, you get the string "42" — and then "42" + 1 becomes "421". Always serialize with JSON.stringify on write, and deserialize with JSON.parse on read. The hook above handles this automatically, which is one immediate reason the extraction pays off.
Type into the fields below, then click Simulate Page Reload. In "Without" mode the values vanish on reload — state resets to initialValue. In "With useLocalStorage" mode the values are restored exactly as you left them. Watch the storage display to see what's written on each keystroke.
In "Without" mode the storage box stays empty and every reload clears the inputs — there's nowhere to persist to or restore from. In "With useLocalStorage" mode each keystroke immediately writes to the storage display, and clicking reload reads back those exact values. The component's state re-initializes from storage instead of falling back to the empty initialValue.
A search input fires onChange on every keystroke. Triggering an API call each time means typing "react hooks" fires 11 requests — most of them wasted on incomplete words. useDebounce delays updating a value until the user stops typing for a given number of milliseconds.
import { useState, useEffect } from 'react'; export function useDebounce(value, delay = 500) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { // Schedule an update after `delay` ms of inactivity const timer = setTimeout(() => { setDebouncedValue(value); }, delay); // Cleanup: if `value` changes before the timer fires, cancel and reschedule return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; }
Every time value changes, the useEffect fires. Before scheduling a new timer, React calls the previous effect's cleanup — which calls clearTimeout, cancelling the pending timer. Only the last change survives long enough to fire. The debounced value only updates when the input has been stable for the full delay duration.
import { useState, useEffect } from 'react'; import { useDebounce } from './hooks/useDebounce'; function SearchBar() { const [query, setQuery] = useState(''); // Only changes after the user stops typing for 500 ms const debouncedQuery = useDebounce(query, 500); // This effect fires once per "pause", not once per keystroke useEffect(() => { if (debouncedQuery) searchAPI(debouncedQuery); }, [debouncedQuery]); return ( <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search…" /> ); }
Debounce — wait until activity stops, then fire once. Best for search: you want the result for the final query, not every intermediate character. Throttle — fire at most once per interval while activity continues. Best for scroll or resize handlers where you want periodic updates during the action. useDebounce covers the first; a useThrottle hook would cover the second.
Type rapidly into the field. The Live value updates on every keystroke. The Debounced value only updates after you pause for 600 ms — the moment a real API call would fire. Watch the green progress bar reset on each keystroke and fill up only when you stop.
Type a phrase quickly — Keystrokes climbs fast while Searches Fired barely moves. Each keystroke resets the green bar to zero and restarts the 600 ms countdown. Only when you actually pause does the bar fill to 100%, the debounced value update, and a search "fire." The Requests Saved counter shows how many wasted API calls debouncing avoided.
The loading / error / data triple of useState + a useEffect that fetches from a URL is something you'll write over and over. useFetch is the canonical example of a hook composing multiple built-in hooks into a single reusable abstraction.
import { useState, useEffect } from 'react'; export function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!url) return; let cancelled = false; // prevents setting state after unmount setLoading(true); setError(null); fetch(url) .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then(json => { if (!cancelled) { setData(json); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err.message); setLoading(false); } }); return () => { cancelled = true; }; // cleanup: ignore late responses on url change }, [url]); return { data, loading, error }; }
import { useFetch } from './hooks/useFetch'; function UserProfile({ userId }) { const { data: user, loading, error } = useFetch( `/api/users/${userId}` ); if (loading) return <Spinner />; if (error) return <ErrorMessage message={error} />; return <h1>{user.name}</h1>; } // When userId changes, the hook automatically refetches — it's a dependency. // The cleanup function marks any in-flight request as cancelled. // The same hook works for any URL: useFetch('/api/posts'), useFetch('/api/me')
Without the cancelled flag, if a user switches from profile 1 to profile 2 quickly and request 1 resolves after request 2, you'd momentarily see profile 1's data over profile 2's. The cancelled = true cleanup prevents this: when url changes, React runs the previous effect's cleanup — setting cancelled = true — before the new effect fires. Any late-arriving response is silently ignored.
In production, reach for TanStack Query or SWR instead of hand-rolling useFetch. They add caching, background refetching, pagination, and optimistic updates. But understanding how this hook works is exactly how you'll debug those libraries when something goes wrong — and the pattern is identical.
Not every piece of logic needs its own hook. The overhead of naming and writing one isn't worth it for single-use, trivial code. Here's a practical guide for when extraction actually pays off.
useState + useEffect pattern appears in two or more componentsuseState with no surrounding logic| Scenario | Best approach | Why |
|---|---|---|
Two components both fetch /api/user with loading/error state |
useFetch hook |
Shared logic with a side effect — the canonical case for extraction |
| Formatting a date for display | Plain formatDate() function |
Pure calculation, no hooks needed — a regular function is simpler and more reusable |
| A modal's open/close state, used in one place | Keep useState inline |
One line, used once — extracting useModal is premature abstraction |
| Subscribing to a WebSocket with connect/disconnect cleanup | useWebSocket hook |
Side effect with cleanup — perfectly suited for useEffect's return function |
An input's value + onChange in one form |
Inline, or useField if repeated across many forms |
Small gain for a single form; real value shows when you have 10+ forms sharing the same validation |
A useful heuristic: write the logic once inline, copy it a second time and tolerate the duplication, but when you're about to copy it a third time — stop and extract a hook. The third repetition is when you have enough real-world examples to understand what the hook's inputs and outputs should actually be, producing a much better abstraction than you'd write after seeing it only once.
Functions starting with use that call other hooks. They encapsulate and share stateful logic — not state itself — between components.
Each call to a custom hook creates its own isolated state instance. Two components using the same hook do not share state.
A useState drop-in that serializes every update to localStorage and reads from it on mount, making state survive page reloads.
Delays a value update until input pauses for N ms. Uses useEffect's cleanup to cancel and reschedule the timer on every change.
useCounter(0) from the same hook file. A increments to 5. What is B's counter value?useDebounce, what does the cleanup function returned from useEffect actually do?useLocalStorage use JSON.stringify on write and JSON.parse on read?You now know how to extract, name, and compose custom hooks — the most powerful code-sharing primitive in React. In the next session we'll explore advanced patterns: compound components, the render-props pattern, and combining Context with custom hooks to build scalable feature modules.