useMediaQuery(query)
Returns a boolean. A matchMedia subscription with cleanup. Reach for it instead of polling window.innerWidth on every resize event.
Packaging React's built-in hooks into reusable named behaviors, and deciding when extraction is worth it.
A dashboard you’re working on lazy-loads images as they scroll into view. The product grid does it, the activity feed does it, the team-members list does it. All three carry the same six lines: a useRef for the element, a useEffect that wires up an IntersectionObserver, and a useState flag that flips to true once the element crosses the viewport. One block of wiring, copied verbatim.
When a fourth screen needs the same behavior, you can paste the block again, or pull those six lines into a function called useIntersection so every screen calls useIntersection(ref) and reads back a boolean. That function is a custom hook, and deciding when to write one is the subject of this lesson.
Extraction is a judgment call, not a reflex. Pulled too early, it buries simple code under indirection; pulled at the right moment, it turns a tangle into a name. By the end of this lesson you’ll be able to decide whether a behavior is worth extracting, name the hook so it reads right, choose what it returns, and recognize the handful of custom hooks a 2026 web app reaches for again and again. Nothing new gets added to React here; a custom hook is just packaging, wrapping the primitives you already know into named, reusable behaviors.
Here is the whole definition: a custom hook is a JavaScript function whose name starts with use and that calls one or more hooks inside it, built-in or custom. That’s it. No special API, no registration step, nothing to import beyond the hooks you were already importing. If you can write a function, you can write a custom hook.
The part that trips people up is the name. The use prefix is a contract, and two audiences read it. The linter treats any use-named function as a hook and holds it to the rules of hooks . The next person who reads your code reads the prefix the same way: call this at the top level, unconditionally, never inside an if. The prefix is how that guarantee travels from a built-in hook out to the functions you write yourself.
So let’s make the useIntersection extraction concrete. The comparison below puts the copied-everywhere version next to the extracted one: the same behavior, before and after it earns a name.
const ProductCard = ({ product }: { product: Product }) => { const ref = useRef<HTMLDivElement>(null); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) setIsVisible(true); }); observer.observe(el); return () => observer.disconnect(); }, []);
return ( <div ref={ref}>{isVisible ? <img src={product.image} /> : null}</div> );};Repeated verbatim in three components. Each carries the full IntersectionObserver wiring: the ref, the effect, the cleanup, and the state flag.
const useIntersection = (ref: RefObject<Element | null>): boolean => { const [isVisible, setIsVisible] = useState(false);
useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) setIsVisible(true); }); observer.observe(el); return () => observer.disconnect(); }, [ref]);
return isVisible;};
const ProductCard = ({ product }: { product: Product }) => { const ref = useRef<HTMLDivElement>(null); const isVisible = useIntersection(ref); return ( <div ref={ref}>{isVisible ? <img src={product.image} /> : null}</div> );};One recipe, called wherever the behavior is needed. The component shrinks to the line that matters: is this element on screen yet?
The hook is the effect, the state, and the ref, lifted out of the component and given a name that says what the wiring is for. What’s left reads like a sentence: hold a ref, ask whether it’s on screen, render accordingly.
One corollary falls out of the definition, and it’s the most common way the contract gets abused: a use-named function that calls no hooks is misnamed. If your function only shapes data, reformatting a price or sorting an array, and touches no useState, useEffect, or other hook, it’s a plain function. Drop the use prefix and ship it from lib/. Naming a hookless function useFormatPrice misleads the linter, which now polices it for rules it doesn’t need, and the reader, who expects it to behave like a hook. The prefix is a promise; only make it when it’s true.
Call the same custom hook in two components and they share the code, the wiring recipe, and nothing else. Each call site gets its own useState cell, its own useEffect subscription, and its own ref. Two components calling useIntersection(ref) are not watching the same element or pooling one boolean; each has a completely independent visibility flag that knows nothing about the other.
A custom hook is a recipe, and each component that calls it cooks its own dish: the instructions are shared, the meal is not.
The diagram makes this literal. One useDebounced recipe sits at the top, and the two sibling components that call it each get a private [value, setValue] cell. Type into the search field and only its cell changes; the filter field’s debounced value sits untouched.
useDebounced(value, delay)
a useState + a useEffect timer
<SearchField /> [searchValue, setSearchValue] its own state <FilterField /> [filterValue, setFilterValue] its own state The misconception causes real bugs. A developer writes useToggle() expecting the header and the sidebar to share one open/closed flag, ships it, then is baffled that toggling one does nothing to the other. A custom hook is the right tool when you want the same behavior in many places, and the wrong tool when you want the same state in many places.
When you genuinely need shared state, reach for a different tool: lift the state to a common parent and pass it down, put it in a context when the consumers sit far apart in the tree, or use an external store for app-wide state that lives outside the tree, which you’ll meet later in the course.
A custom hook is just a function, so its shape is a function signature: what goes in, what comes out. A few conventions make a hook feel native rather than improvised, and they pay off when you hold to them across a codebase.
Everything the hook needs comes in as an argument. If useIntersection needs an element, you pass it the ref; if useDebounced needs a value and a delay, you pass both. Avoid a hook that reads a value from the calling component through anything other than its own parameters: it now depends on a variable that isn’t in its signature, so it breaks the moment a second component with a different layout tries to use it. If the hook needs it, pass it in.
Everything the hook produces comes out as the return value. Three shapes cover the cases, chosen by how many things come back.
const isOnline = useOnlineStatus();const [draft, setDraft] = useLocalStorage('draft', '');const { data, isLoading, error } = useFetch('/api/invoices');For a single output, return it bare; a tuple or object would just be ceremony. For a value paired with a way to change it, return a tuple that mirrors useState, so the destructuring feels familiar. For three or more, return an object, so each call site names what it pulls out and the order stops mattering; a caller can take only data without counting commas. The rule is the one the code conventions use everywhere: tuples when the order is the meaning, objects when the names are.
What matters more than which shape you pick is picking one and holding to it. A hook that returns a tuple in one case and an object in another breaks every call site that guessed wrong and forces readers into the implementation to check. Decide the shape once, when you write the hook, and never make it conditional.
A hook that passes a value through it, taking something in and handing the same kind of thing back, should be generic , so its type is decided by the caller rather than frozen when you write it. useLocalStorage is the textbook case: it can store a string draft, a number, or a boolean preference, and the caller shouldn’t have to cast or annotate the value. A single type parameter lets one definition serve all of them.
const useLocalStorage = <T,>(key: string, initial: T): [T, (value: T) => void] => { // ...read and write localStorage under the key...};
const [draft, setDraft] = useLocalStorage('draft', '');const [count, setCount] = useLocalStorage('count', 0);The payoff is at the call sites. useLocalStorage('draft', '') infers T = string, so setDraft only accepts strings and draft reads as a string everywhere. useLocalStorage('count', 0) infers T = number from the same definition. You write the hook once, and the types specialize themselves per call.
Two conventions show in that signature: a hook is bound to a const as an arrow function, the same as components and callbacks, and its return type is annotated explicitly even though TypeScript could infer it, because spelling out [T, (value: T) => void] documents the contract at a glance.
Anyone can move six lines into a function. Knowing when that move pays off, and when it just adds a layer to read through later, is the harder skill. Extract a custom hook when any one of these three conditions holds.
Reuse. The behavior appears in two or more components, and the wiring is more than a line or two. This is the useIntersection case from the start of the lesson: six lines of observer wiring, copied across three screens and about to land in a fourth. Extraction stops the copying and gives the behavior one home to fix bugs in.
Clarity. A single component holds a knot of coordinated hooks, an effect, some state, and a ref all working toward one job. Lifting that knot into a named hook reveals what the component is actually doing, even if no other component will ever call it. Readability alone justifies the move: a 120-line component with three intertwined effects becomes legible the moment two of them go behind useAutosave() and usePresence().
Encapsulation. A behavior wraps an external system, a browser API or third-party SDK, with effect, state, ref, and cleanup wiring that should hide behind a clean interface. useMediaQuery hides a matchMedia subscription, useIntersection hides an IntersectionObserver, and anything wrapping addEventListener fits here. The component shouldn’t know the external API exists; it asks a question and gets an answer.
Over-extraction is its own mess. Do not extract when there’s nothing to reveal: a single useState(false) renamed useToggle and called once just adds a file, an import, and a layer of indirection over one already-clear line. And do not extract a function that calls no hooks. That isn’t a hook, it’s a utility, and it belongs in lib/ without the use prefix.
One case sits right on this boundary, the topic of the next section: a hook that takes a callback and calls it from inside an effect. The walkthrough below runs the questions in the order an experienced engineer asks them, cheapest cut first. Click through it on a behavior you’re unsure about.
No hooks means it isn’t a hook. The use prefix would lie to the linter and the reader. Give it a verb-led name and export it from lib/.
The encapsulation condition. Hide the external API’s subscribe/cleanup machinery behind a clean question-and-answer interface so the component never sees it.
The reuse condition. A behavior wired in two-plus places, non-trivial enough to copy, has earned a single home. Fix bugs once, not three times.
A single caller is fine. If naming the block makes the component readable, that alone justifies it. Readability is a first-class reason.
The over-extraction guard. A one-line useState wrapped in a hook adds indirection without revelation. Keep simple state where it’s used.
Asking whether the block calls hooks first, then reuse and clarity with “leave it inline” as the floor, makes “leave it inline” and “it’s just a utility” real answers rather than failures to extract. The encapsulation question jumps the queue: a browser API or third-party widget is almost always worth hiding before you know whether you’ll reuse it.
useEffectEventA hook called useOnClickOutside shows a pitfall that every callback-accepting hook shares.
The behavior is a dropdown-and-modal staple: close when the user clicks outside this element. You call useOnClickOutside(ref, handler), and the hook attaches a pointerdown listener in an effect that fires handler when a click lands outside the element ref points at. The call site is as simple as it should be:
useOnClickOutside(menuRef, () => setOpen(false));handler is a fresh inline arrow, recreated on every render. That is exactly how a caller should pass it: no caller should have to wrap it or reason about its identity.
But that fresh arrow is a problem inside the hook. The naive implementation lists handler in the effect’s dependency array, since the effect reads it and the exhaustive-deps rule wants every value the effect reads. Because handler’s identity changes every render, the effect tears down and re-attaches its listener every render. That is wasteful, and there is a window between removing the old listener and adding the new one where a click can slip through unhandled.
The fix is useEffectEvent, applied inside the hook. Wrap the caller’s handler with useEffectEvent . The wrapped function always calls the latest handler, but its identity is stable, so the effect can depend on [ref] alone. The listener attaches once. The caller still passes any inline arrow they like, now for free.
const useOnClickOutside = ( ref: RefObject<HTMLElement | null>, handler: (event: PointerEvent) => void,) => { const onClickOutside = useEffectEvent(handler);
useEffect(() => { const listener = (event: PointerEvent) => { const el = ref.current; if (!el || el.contains(event.target as Node)) return; onClickOutside(event); }; document.addEventListener('pointerdown', listener); return () => document.removeEventListener('pointerdown', listener); }, [ref]);};The hook takes a ref and a plain handler. Consumers pass an inline arrow and never wrap it themselves.
const useOnClickOutside = ( ref: RefObject<HTMLElement | null>, handler: (event: PointerEvent) => void,) => { const onClickOutside = useEffectEvent(handler);
useEffect(() => { const listener = (event: PointerEvent) => { const el = ref.current; if (!el || el.contains(event.target as Node)) return; onClickOutside(event); }; document.addEventListener('pointerdown', listener); return () => document.removeEventListener('pointerdown', listener); }, [ref]);};Wrap the consumer’s handler. onClickOutside always calls the latest handler, with a stable identity across renders.
const useOnClickOutside = ( ref: RefObject<HTMLElement | null>, handler: (event: PointerEvent) => void,) => { const onClickOutside = useEffectEvent(handler);
useEffect(() => { const listener = (event: PointerEvent) => { const el = ref.current; if (!el || el.contains(event.target as Node)) return; onClickOutside(event); }; document.addEventListener('pointerdown', listener); return () => document.removeEventListener('pointerdown', listener); }, [ref]);};The effect attaches one pointerdown listener, which fires only when the click lands outside the element.
const useOnClickOutside = ( ref: RefObject<HTMLElement | null>, handler: (event: PointerEvent) => void,) => { const onClickOutside = useEffectEvent(handler);
useEffect(() => { const listener = (event: PointerEvent) => { const el = ref.current; if (!el || el.contains(event.target as Node)) return; onClickOutside(event); }; document.addEventListener('pointerdown', listener); return () => document.removeEventListener('pointerdown', listener); }, [ref]);};Only ref is a dependency. Because onClickOutside is stable, it is correctly left out, so the listener attaches once and never re-attaches.
const useOnClickOutside = ( ref: RefObject<HTMLElement | null>, handler: (event: PointerEvent) => void,) => { const onClickOutside = useEffectEvent(handler);
useEffect(() => { const listener = (event: PointerEvent) => { const el = ref.current; if (!el || el.contains(event.target as Node)) return; onClickOutside(event); }; document.addEventListener('pointerdown', listener); return () => document.removeEventListener('pointerdown', listener); }, [ref]);};Cleanup removes the listener when the component unmounts or the ref changes.
This generalizes. Whenever a hook takes a function from its caller and calls it inside an effect, wrap it with useEffectEvent so the effect does not re-run every time the caller re-renders. The caller stays free to pass inline functions; the hook stays stable underneath.
A custom hook can call other custom hooks. A custom hook is just a function that may call hooks, and “hooks” includes the ones you wrote, so composition comes free with the use contract.
Two examples show the shape.
const useFilteredList = (items: Item[], query: string) => { const deferredQuery = useDeferredValue(query); return items.filter((item) => item.name.includes(deferredQuery));};
const usePaginatedData = (query: string) => { const page = Number(useSearchParams().get('page') ?? '1'); return useFetch(`/api/search?q=${query}&page=${page}`);};useFilteredList wraps useDeferredValue, the built-in that lets an expensive update lag behind without blocking typing, into a single “give me the filtered list, kept responsive.” The caller never has to know useDeferredValue is involved. usePaginatedData combines a data-fetching hook with useSearchParams, the Next.js hook that reads the current page from the URL (you’ll meet it properly in the App Router chapter).
The rule is short: compose freely, and flatten only when nesting hides where state lives. Stacking hooks inside hooks is fine until you can no longer tell which layer owns the value you’re debugging. That’s the signal to inline one layer back.
A handful of custom hooks show up in nearly every web app, and the point is not to memorize their implementations. It’s to recognize the shape the moment a behavior calls for it, so you reach for the named hook, or a vetted one from a library, instead of pasting a raw useEffect block for the fifth time. We’ll build one in full, then list the rest as a recognition reference.
useLocalStorageuseLocalStorage(key, initial) keeps a piece of state synchronized with the browser’s localStorage, so a value survives reloads and stays in sync across tabs. It’s a good flagship because it exercises everything in this lesson, and it has one detail the obvious implementation gets wrong: server rendering.
The tempting version is useState(() => localStorage.getItem(key)), and it fails twice. During SSR there is no localStorage, because window doesn’t exist, so that line throws and the render crashes. Guard against that, and the server and the first client render still disagree about the value, so React flags a hydration mismatch. The fix is to build the hook on useSyncExternalStore , the primitive for subscribing React to a value that lives outside it. It takes a dedicated server-snapshot function precisely so server and client can agree.
useSyncExternalStore takes three functions, and each one earns its place.
const subscribe = (onChange: () => void) => { window.addEventListener('storage', onChange); return () => window.removeEventListener('storage', onChange);};
const cache = new Map<string, { raw: string | null; value: unknown }>();
const useLocalStorage = <T,>(key: string, initial: T): [T, (value: T) => void] => { const getSnapshot = (): T => { let raw: string | null; try { raw = window.localStorage.getItem(key); } catch { return initial; } const cached = cache.get(key); if (cached && cached.raw === raw) return cached.value as T; const value = raw === null ? initial : (JSON.parse(raw) as T); cache.set(key, { raw, value }); return value; };
const getServerSnapshot = (): T => initial;
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = (next: T) => { window.localStorage.setItem(key, JSON.stringify(next)); window.dispatchEvent(new StorageEvent('storage', { key })); };
return [value, setValue];};subscribe: the first useSyncExternalStore argument. It registers a listener and returns the unsubscribe function. Because it depends on nothing inside the hook, it lives at module scope as a single stable reference that’s never recreated per render, so React never needlessly re-subscribes. The storage event fires when localStorage changes, including writes from other tabs, which is how cross-tab sync comes for free.
const subscribe = (onChange: () => void) => { window.addEventListener('storage', onChange); return () => window.removeEventListener('storage', onChange);};
const cache = new Map<string, { raw: string | null; value: unknown }>();
const useLocalStorage = <T,>(key: string, initial: T): [T, (value: T) => void] => { const getSnapshot = (): T => { let raw: string | null; try { raw = window.localStorage.getItem(key); } catch { return initial; } const cached = cache.get(key); if (cached && cached.raw === raw) return cached.value as T; const value = raw === null ? initial : (JSON.parse(raw) as T); cache.set(key, { raw, value }); return value; };
const getServerSnapshot = (): T => initial;
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = (next: T) => { window.localStorage.setItem(key, JSON.stringify(next)); window.dispatchEvent(new StorageEvent('storage', { key })); };
return [value, setValue];};The snapshot cache. useSyncExternalStore compares snapshots with Object.is, so getSnapshot must return the same reference when the underlying data hasn’t changed, or React loops forever. This module-level map remembers, per key, the last raw string and the value it parsed to.
const subscribe = (onChange: () => void) => { window.addEventListener('storage', onChange); return () => window.removeEventListener('storage', onChange);};
const cache = new Map<string, { raw: string | null; value: unknown }>();
const useLocalStorage = <T,>(key: string, initial: T): [T, (value: T) => void] => { const getSnapshot = (): T => { let raw: string | null; try { raw = window.localStorage.getItem(key); } catch { return initial; } const cached = cache.get(key); if (cached && cached.raw === raw) return cached.value as T; const value = raw === null ? initial : (JSON.parse(raw) as T); cache.set(key, { raw, value }); return value; };
const getServerSnapshot = (): T => initial;
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = (next: T) => { window.localStorage.setItem(key, JSON.stringify(next)); window.dispatchEvent(new StorageEvent('storage', { key })); };
return [value, setValue];};The generic signature: takes a key and a typed initial value, returns the familiar [value, setValue] tuple. T flows from the call site, exactly like the generic section showed.
const subscribe = (onChange: () => void) => { window.addEventListener('storage', onChange); return () => window.removeEventListener('storage', onChange);};
const cache = new Map<string, { raw: string | null; value: unknown }>();
const useLocalStorage = <T,>(key: string, initial: T): [T, (value: T) => void] => { const getSnapshot = (): T => { let raw: string | null; try { raw = window.localStorage.getItem(key); } catch { return initial; } const cached = cache.get(key); if (cached && cached.raw === raw) return cached.value as T; const value = raw === null ? initial : (JSON.parse(raw) as T); cache.set(key, { raw, value }); return value; };
const getServerSnapshot = (): T => initial;
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = (next: T) => { window.localStorage.setItem(key, JSON.stringify(next)); window.dispatchEvent(new StorageEvent('storage', { key })); };
return [value, setValue];};getSnapshot: reads the raw string and returns a cached parsed value. If the raw string matches what the cache last saw, it hands back the very same reference, so an object or array value stays referentially stable across renders. Only a changed raw string triggers a fresh JSON.parse. The read is wrapped in try/catch so a missing or corrupt value falls back to initial rather than throwing.
const subscribe = (onChange: () => void) => { window.addEventListener('storage', onChange); return () => window.removeEventListener('storage', onChange);};
const cache = new Map<string, { raw: string | null; value: unknown }>();
const useLocalStorage = <T,>(key: string, initial: T): [T, (value: T) => void] => { const getSnapshot = (): T => { let raw: string | null; try { raw = window.localStorage.getItem(key); } catch { return initial; } const cached = cache.get(key); if (cached && cached.raw === raw) return cached.value as T; const value = raw === null ? initial : (JSON.parse(raw) as T); cache.set(key, { raw, value }); return value; };
const getServerSnapshot = (): T => initial;
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = (next: T) => { window.localStorage.setItem(key, JSON.stringify(next)); window.dispatchEvent(new StorageEvent('storage', { key })); };
return [value, setValue];};getServerSnapshot: the detail that matters. During SSR and the first client render there is no localStorage, so this returns initial, guaranteeing server and client agree and hydration doesn’t mismatch.
const subscribe = (onChange: () => void) => { window.addEventListener('storage', onChange); return () => window.removeEventListener('storage', onChange);};
const cache = new Map<string, { raw: string | null; value: unknown }>();
const useLocalStorage = <T,>(key: string, initial: T): [T, (value: T) => void] => { const getSnapshot = (): T => { let raw: string | null; try { raw = window.localStorage.getItem(key); } catch { return initial; } const cached = cache.get(key); if (cached && cached.raw === raw) return cached.value as T; const value = raw === null ? initial : (JSON.parse(raw) as T); cache.set(key, { raw, value }); return value; };
const getServerSnapshot = (): T => initial;
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = (next: T) => { window.localStorage.setItem(key, JSON.stringify(next)); window.dispatchEvent(new StorageEvent('storage', { key })); };
return [value, setValue];};setValue: writes the new value as JSON and dispatches a storage event so same-tab subscribers re-read immediately (the native event only fires in other tabs).
This one hook touches nearly everything in the lesson: the generic lets a draft string and a saved count share one definition, the tuple lets the call site destructure it like useState, and the three store functions make it safe on the server, reactive to other tabs, and crash-proof on bad data. A project later in the course will hand it to you to build, so you’ll see it again.
These hooks round out the set worth recognizing. Each card names the hook, its return shape, and the failure it prevents, not its implementation. When you feel one of these behaviors coming on, reach for the named hook, yours or a library’s, rather than wiring up the primitive by hand again.
useMediaQuery(query)
Returns a boolean. A matchMedia subscription with cleanup. Reach for it instead of polling window.innerWidth on every resize event.
useIntersection(ref, options)
Returns visibility / the observer entry. Wraps IntersectionObserver. Replaces hand-rolled scroll-handler position math for lazy-loading and scroll-spy.
useDebounced(value, delay)
Returns the deferred value. Value-shaped, not callback-shaped, so the result flows through dependency arrays naturally, unlike the old debounced-callback pattern. (useThrottled is its rate-limited sibling.)
useLockBodyScroll()
Returns nothing. Toggles overflow: hidden on <body> with cleanup. Stops the page behind an open modal from scrolling. (A project later in the course consumes this one.)
usePrevious(value)
Returns the value from the previous render. The one legitimate effect-driven previous-value pattern. Saves you from reaching for extra state just to remember what a value was last render.
useCopyToClipboard()
Returns [copy, copied]. Wraps the Clipboard API with a transient copied flag that resets itself. Stops you hand-rolling the reset timeout every time you build a copy button.
useOnClickOutside(ref, handler)
Returns nothing. The useEffectEvent-inside hook from earlier. Kills the dropdown/modal “click outside to close” boilerplate.
Most custom hooks should not take a dependency array. The default is a focused, value-shaped API: useDebounced(value, delay) takes the value, not a list of dependencies, so the caller passes the thing and the hook owns the wiring. That shape is easier to use correctly and gives the caller one fewer way to go wrong. The React team’s own guidance points the same way: most custom hooks should expose a higher-level API, not a raw dependency array.
Keep the dep-array shape for the rare hook that genuinely needs the caller to control re-runs, something like useInterval(callback, deps), where the caller decides when the interval resets. That hook has a problem the built-ins don’t: exhaustive-deps won’t check its dependency array, because the rule only knows React’s own hooks. Your custom dep-array hook can drift out of sync with its dependencies and the linter stays silent.
The fix is one line of config. You give the rule the names of your dep-array hooks so it checks them too.
'react-hooks/exhaustive-deps': ['warn', { additionalHooks: '(useInterval|useIsomorphicLayoutEffect)',}],The additionalHooks regex lists the hooks whose final argument is a dependency array. From then on the lint enforces exhaustive deps on them exactly as it does on useEffect. You met the react-hooks rules in the previous chapter; this is where you extend them to your own hooks.
The badge below reads navigator.onLine into state and subscribes to the window online and offline events, all inline in App. This is the encapsulation case: a browser API with subscribe-and-cleanup wiring that belongs behind a name.
Extract that logic into a useOnlineStatus() hook in the same file, returning a boolean, and have App consume it. The output stays identical, since the refactor changes structure, not behavior.
This badge wires navigator.onLine plus the online/offline window events straight into App. Extract that logic into a custom hook named useOnlineStatus, defined above App in this same file, that returns a boolean — then have App call it and render the same badge. The rendered output must not change.
If you get stuck, the reference solution is below. Try the extraction first.
import { useState, useEffect } from 'react';
const useOnlineStatus = (): boolean => { const [isOnline, setIsOnline] = useState( typeof navigator === 'undefined' ? true : 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 isOnline;};
export function App() { const isOnline = useOnlineStatus(); return ( <div className="p-4"> <span className={isOnline ? 'text-green-600' : 'text-red-600'}> {isOnline ? 'Online' : 'Offline'} </span> </div> );}App now reads as a single question, am I online?, with the wiring behind the name.
Drag each snippet to where it belongs: extract a custom hook, ship it from lib/ as a plain utility, or leave it inline.
Sort each snippet by what you'd do with it: extract a hook, ship a /lib utility, or leave it inline. Drag each item into the bucket it belongs to, then press Check.
IntersectionObserver wiring used in three componentsuseState(false) toggle used in one componentformatCurrency(cents) with no hooksmatchMedia subscription with cleanup, used by the responsive navslugify(title) string transformuseRef for a DOM node read in one event handlerThe next lesson turns to the React Compiler, which auto-memoizes this hook code and reshapes when you’d reach for manual memoization at all.
The official guide to extracting and composing custom hooks, including the share-code-not-state model.
The reasoning behind the useEffectEvent wrap that keeps callback-accepting hooks from thrashing.
Reference for the primitive behind the SSR-safe useLocalStorage, including the server-snapshot contract.
A vetted, typed catalog of the hooks in this lesson — reach for these before re-deriving the wiring.