Lesson 7 · Hooks in depth
Custom hooks
How to extract and reuse logic: useLocalStorage, useMediaQuery, useDebounce…
Loading lesson…
Lesson 7 · Hooks in depth
How to extract and reuse logic: useLocalStorage, useMediaQuery, useDebounce…
Loading lesson…
White sauce is in the cookbook only once, but it's used in lasagne, in potato gratin and in many other dishes. The lasagne recipe doesn't describe again how to make a roux and how much milk to pour in. It just says "make a white sauce (p. 12)". That makes the recipe shorter and more readable, and when the white sauce method improves, it improves in every dish at once.
A custom hook is a basic recipe like that. When the same combination of hooks keeps repeating in your components – state, an effect, an event listener, a cleanup – you pull it out into a function named use… and the component then just writes useOnlineStatus().
An important detail: when you cook lasagne and potatoes at the same time, they don't share one pot of white sauce. Each dish gets its own sauce, made from the same recipe. It's the same with hooks: a hook shares the method (the logic), not the pot (the state). Two components that call the same hook each have their own, independent state.
We want to show in the header whether the user is online. The browser knows (navigator.onLine) and on a change it sends an online or offline event. From the lesson on effects you already know how to write it:
function StatusBar() {const [isOnline, setIsOnline] = useState(navigator.onLine)useEffect(() => {const goOnline = () => setIsOnline(true)const goOffline = () => setIsOnline(false)window.addEventListener('online', goOnline)window.addEventListener('offline', goOffline)return () => {window.removeEventListener('online', goOnline)window.removeEventListener('offline', goOffline)}}, [])return <p>{isOnline ? '🟢 Online' : '🔴 Offline'}</p>}
Then a new requirement comes: the "Save" button should be disabled when offline. The button is a different component, so we'd have to copy the whole state with the effect. Instead, we'll pull it out into a custom hook.
function useOnlineStatus() {const [isOnline, setIsOnline] = useState(navigator.onLine)useEffect(() => {// …the same effect as above…}, [])return isOnline}
use. That's how React (and the linter) know there are hooks inside and the rules of hooks apply to it.useState, an object.function StatusBar() {const isOnline = useOnlineStatus()return <p>{isOnline ? '🟢 Online' : '🔴 Offline'}</p>}function SaveButton() {const isOnline = useOnlineStatus()return <button disabled={!isOnline}>{isOnline ? 'Save' : 'Waiting for a connection…'}</button>}
The components now read like a sentence: "find out whether I'm online and display accordingly". How that's found out is hidden in the "recipe on p. 12".
And mind the pots: StatusBar and SaveButton each have their own useState and their own effect with its own listeners. The values match only because both listen to the same browser – not because they share state.
A typical hook that connects state with "the world outside". It behaves like useState, but it also saves the value into the browser's localStorage, so it doesn't disappear after a page reload (F5).
function useLocalStorage<T>(key: string, initialValue: T) {// 1) Lazy initialisation: we read from storage only on the first renderconst [value, setValue] = useState<T>(() => {const stored = localStorage.getItem(key)return stored !== null ? JSON.parse(stored) : initialValue})// 2) An effect: after every change, write the value into storageuseEffect(() => {localStorage.setItem(key, JSON.stringify(value))}, [key, value])// 3) Return the same pair as useStatereturn [value, setValue] as const}// Usage – looks exactly like useState:const [note, setNote] = useLocalStorage('note', '')
useState looks into storage. If there's something there (from a previous visit), it uses it; otherwise the initial value.setNote changes the state, the component re-renders and the effect writes the value into storage.as const at the end tells TypeScript that we return a pair [value, setter] in a fixed order, not an array that can hold either of the two anywhere.
What you see: A note field and a font size – both use useLocalStorage. The preview button uses a second small hook, useToggle, which returns a value and a function to flip it.
Try it:
useToggle saves nothing, it's ordinary state.The takeaway: The component uses useLocalStorage just like useState. That the value gets saved is hidden in the hook.
Loading the interactive part…
In the lesson on state we said state is a notebook React keeps for the cook. Now for a detail: the pages of that notebook have no names, only numbers. React doesn't remember "the name page", but "the first useState in the component, the second useState, the third…". On every render it goes through the hooks in the order they're called and opens the next page for each.
What happens when you call a hook only sometimes:
function Form({ showEmail }: { showEmail: boolean }) {const [name, setName] = useState('')if (showEmail) {const [email, setEmail] = useState('') // ❌ a hook inside a condition}const [age, setAge] = useState(0)}
| Order | showEmail = true | showEmail = false |
|---|---|---|
| Page 1 | name | name |
| Page 2 | email | age – but the page holds the e-mail! |
| Page 3 | age | (nobody) |
When showEmail switches to false, the third hook gets the second page of the notebook – the one with the e-mail. The state "shifts" and the component works with someone else's data. That's why the order of hooks must be the same on every render.
| Rule | Why |
|---|---|
| Call hooks only at the top level – not in conditions, loops or after an early return | React pairs state with hooks by the call order. A different order = a different page of the notebook. |
| Call them only from components and other hooks | Outside a render React doesn’t know whose notebook to open. |
The name starts with use | That’s how the linter recognises a hook and checks the rules and dependencies. |
The exception: use() | The use() hook (for a Promise and Context) doesn’t use a page of the notebook, so it may be inside a condition too. |
Our useOnlineStatus from section 1 works, but it has a weak spot: it keeps the value in a copy (its own useState) and hopes the effect manages to update it. In the short moment between the change and the effect, the component can show a stale value.
It's better to behave like a newspaper subscriber. You don't keep your own copy; you tell the publisher: "let me know when a new issue comes out" (subscribe). And whenever you need the current state, you look in the letterbox (getSnapshot). That's exactly what useSyncExternalStore does:
// The subscription: React gives you an onChange function, you register it with the browserfunction subscribe(onChange: () => void) {window.addEventListener('resize', onChange)return () => window.removeEventListener('resize', onChange) // cancelling the subscription}// A look in the letterbox: the current valuefunction getSnapshot() {return window.innerWidth}function useWindowWidth() {return useSyncExternalStore(subscribe, getSnapshot)}
subscribe and passes it an onChange function. You hang it on the resize event.getSnapshot and gets the current width. No copy in state.onChange. React looks in the letterbox, and if there's a different value, it re-renders the component.subscribe returned.The subscribe function is defined outside the component so it's always the same. If it were created anew on every render, React would cancel and set up the subscription every time. Zustand, Redux and this course's navigation are built on the same principle.
What you see: Three hooks built on useSyncExternalStore: the window width, the system theme (through matchMedia) and the connection status.
Try it:
The takeaway: Each row is one line of code in the component. The subscription, cleanup and reading of the current value are hidden in the hook.
Loading the interactive part…
A hook doesn't have to handle just one specific thing. It can be a general recipe with an "ingredient" as a parameter. A typical example: loading data with a loading / done / error state and protection against the race of responses from the lesson on effects.
function useAsync<T>(fn: () => Promise<T>, deps: unknown[]) {const [state, setState] = useState<AsyncState<T>>({ status: 'loading' })useEffect(() => {let ignore = falsesetState({ status: 'loading' })fn().then((data) => !ignore && setState({ status: 'success', data }),(error) => !ignore && setState({ status: 'error', error }),)return () => { ignore = true }}, deps)return state}// The component handles only WHAT to load and how to show it:function UserCard({ id }: { id: number }) {const user = useAsync(() => fetchUser(id), [id])if (user.status === 'loading') return <p>Loading…</p>if (user.status === 'error') return <p>{user.error.message}</p>return <p>{user.data.name}</p>}
What you see: Two UserCard cards, both using useAsync. The left one loads a user according to the button, the right one is fixed to user #1.
Try it:
error state from the hook.The takeaway: One recipe for loading, each use with its own ingredient and its own state.
Loading the interactive part…
formatPrice) is an ordinary function. Name it without use – otherwise the rules of hooks apply to it for no reason and it can't be called in a condition.useMount(fn) or useUpdate(fn). They hide dependencies and tempt you into effects where they don't belong. Name hooks by their purpose: useChatRoom, useDebounce, useOnlineStatus.For inspiration – these hooks come in handy in almost every project:
useDebounce(value, ms) useInterval(fn, ms)useLocalStorage(key, init) usePrevious(value)useMediaQuery(query) useOnClickOutside(ref, fn)useCopyToClipboard() useIntersectionObserver(ref)useForm(schema) useAuth() / usePermissions()
Write the body of a simple custom hook. The two checkboxes will then work independently of each other.
CHALLENGE: A useToggle hook The component already uses the hook twice. Only its body is missing. 1. In useToggle, create a state: const [on, setOn] = useState(initial) 2. Return an array [on, toggle], where toggle = () => setOn((v) => !v)
row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
useDebounce is the most used hook of all: you take the logic from the challenge in the lesson on effects and turn it into a recipe. The dropdown practises two small hooks with event listeners that come in handy for every menu and modal.
The whole hook is about 8 lines – and the component that uses it gets half as long.
CHALLENGE: A useDebounce hook The generic hook useDebounce(value, delay) returns `value`, but only after `value` hasn't changed for `delay` ms. The signature is ready, and so is the whole UI. 1. In the hook, keep a `debounced` state (initial value = value). 2. In an effect with the dependencies [value, delay], set a setTimeout that calls setDebounced(value) after `delay` ms. Clear the timeout in the cleanup. The hook returns `debounced`. 3. In DebounceDemo, use the hook: debouncedQuery = useDebounce(query, delay). Compare it with the lesson on effects: the logic is the same, but now it's reusable and the component is much more readable.
Loading the interactive part…
Two dropdowns on the page must work independently – a good check that every use of the hook really has its own pot.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
use… function that calls other hooks and hides repeating logic.products)products – from the file src/course/fakeApi.en.ts. The course fake API: data and functions pretending to be a server (with a delay, sometimes even with an error). In a real app you would call fetch() here.export interface Product {id: numbername: stringcategory: 'fruit' | 'vegetables' | 'bakery' | 'dairy'price: numberinStock: boolean}export const products: Product[] = [{ id: 1, name: 'Apple', category: 'fruit', price: 12, inStock: true },{ id: 2, name: 'Banana', category: 'fruit', price: 8, inStock: true },{ id: 3, name: 'Pear', category: 'fruit', price: 15, inStock: false },{ id: 4, name: 'Carrot', category: 'vegetables', price: 6, inStock: true },{ id: 5, name: 'Tomato', category: 'vegetables', price: 9, inStock: true },{ id: 6, name: 'Cucumber', category: 'vegetables', price: 19, inStock: false },{ id: 7, name: 'Bread roll', category: 'bakery', price: 3, inStock: true },{ id: 8, name: 'Bread', category: 'bakery', price: 45, inStock: true },{ id: 9, name: 'Milk', category: 'dairy', price: 22, inStock: true },{ id: 10, name: 'Cheddar', category: 'dairy', price: 39, inStock: true },{ id: 11, name: 'Yogurt', category: 'dairy', price: 14, inStock: false },{ id: 12, name: 'Orange', category: 'fruit', price: 11, inStock: true },]
row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).