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 your own reusable named behaviors, when extraction is worth it and how to shape 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. In all three, the same six lines show up: a useRef for the element, a useEffect that wires up an IntersectionObserver, and a useState flag that flips to true the moment the element crosses the viewport. Three components, one block of wiring, copied verbatim.
Then a fourth screen needs the same behavior. A junior reaches for the clipboard and pastes the block a fourth time. An experienced engineer instead pulls 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.
Custom hooks are where React’s hook system stops being a fixed menu of built-ins and becomes something you extend. 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 SaaS codebase reaches for again and again.
You already have every piece this lesson composes: useState, useReducer, useRef, useEffect, useEffectEvent, useContext, the concurrent hooks, and the rules that govern all of them. Nothing new gets added to the menu here. What’s new is packaging, taking the primitives you know and wrapping them into named, reusable behaviors.
Here is the whole definition: a custom hook is an ordinary JavaScript function whose name starts with use and that calls one or more hooks inside it, whether built-in hooks or other custom hooks. That’s it. There is no special API, no registration step, no createHook factory, 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 not a stylistic convention or a label you add for tidiness. It is a contract, and two different audiences read it. The first is the linter : the react-hooks ESLint plugin treats any use-named function as a hook and holds it to the rules of hooks . The second is the next person who reads your code: the prefix tells them this function obeys those rules, so they should call it at the top level, unconditionally, never inside an if. You met those rules a couple of chapters back, where React tracks each hook call by its position in a fixed call order. 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 from a moment ago concrete. The following comparison 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 one 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?
Nothing moved into the hook that you haven’t written many times before. 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 of the component reads like a sentence: hold a ref, ask whether it’s on screen, render accordingly.
One corollary falls straight out of the definition, and it’s the most common way the contract gets abused. A use-named function that calls no hooks inside it is misnamed. If all your function does is shape some data, such as reformatting a price, building a CSS class string, or sorting an array, and it touches no useState, no useEffect, and no other hook, then it is a plain function, not a hook. Drop the use prefix and ship it from lib/. Naming a hookless function useFormatPrice misleads the linter, which will now police it for rules it doesn’t need, and misleads the reader, who will expect it to behave like a hook. The prefix is a promise; only make it when it’s true.
There’s one fact about custom hooks worth getting right early, because most people’s intuition gets it wrong the first time.
When you call the same custom hook in two different components, they share the code, meaning the wiring recipe, and nothing else. They do not share state. 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, and they are not pooling a single boolean. Each one has a completely independent visibility flag that knows nothing about the other.
A recipe is the clearest way to picture this. A custom hook is a recipe, and each component that calls it cooks its own dish. Writing the recipe down once doesn’t mean two cooks following it share a single plate of food; they each produce their own. The recipe is shared, the meal is not. So a custom hook shares the instructions for wiring up state and effects, but never the state and effects those instructions produce.
The diagram below makes the structure literal. One useDebounced recipe sits at the top. Two sibling components each call it, and each call produces its own private [value, setValue] cell. Type into the search field and only its cell changes; the filter field’s debounced value sits there untouched.
useDebounced(value, delay)
a useState + a useEffect timer
<SearchField /> [searchValue, setSearchValue] its own state <FilterField /> [filterValue, setFilterValue] its own state This matters because the misconception causes real bugs. A developer writes useToggle() expecting that calling it in the header and in the sidebar will keep a single shared open/closed flag, ships it, and is then baffled when toggling one does nothing to the other. The hook was never the problem; it was the wrong tool for the job. 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 state shared across components, you already know the tools for that, and a custom hook is none of them. Lift the state up to a common parent and pass it down. Put it in a context when the consumers are far apart in the tree. Or, for app-wide state that lives outside the tree entirely, reach for an external store, a kind of tool you’ll meet later in the course. Those tools exist to share a single piece of state, whereas a custom hook exists to reproduce a behavior. Keep the two intentions separate and the bugs above never happen.
A custom hook is just a function, so its shape is just a function signature: what goes in, what comes out. A few small conventions make a hook feel native instead of homemade, and they’re worth holding to 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. The case to avoid is a hook that reaches out and reads a value from the calling component through anything other than its own parameters. Such a hook depends on a variable that doesn’t appear 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. There are three shapes worth knowing, chosen by how many things come back. The following block shows all three side by side.
const isOnline = useOnlineStatus();const [draft, setDraft] = useLocalStorage('draft', '');const { data, isLoading, error } = useFetch('/api/invoices');When there’s exactly one output, return it bare. const isOnline = useOnlineStatus() reads perfectly, and a tuple or object would just be ceremony. When the hook hands back a value paired with a way to change it, return a tuple that mirrors useState, so the destructuring feels familiar. const [draft, setDraft] = useLocalStorage('draft', '') reads exactly like the built-in it’s built on. When three or more things come back, return an object, so each call site names what it pulls out and the order stops mattering. const { data, isLoading, error } = useFetch(url) lets a caller take only data without counting commas. The rule of thumb 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 it forces readers to check the implementation to remember which they’re getting. 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 whoever calls it 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 the value or annotate it. 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 is typed as a string everywhere you read it. useLocalStorage('count', 0) infers T = number from the same definition. You wrote the hook once, and the types specialize themselves per call.
Two notes on how that signature is written, both from the code conventions. First, a hook is bound to a const as an arrow function, the default form for hooks in this codebase, the same as components and callbacks. Second, the return type is annotated explicitly here even though TypeScript could infer it, because the signature is worth reading on its own: spelling out [T, (value: T) => void] documents the contract at a glance. The convention is to annotate the return type of an exported function, and of any hook whose signature is itself worth reading.
This is the judgment the lesson has been building toward. Anyone can move six lines into a function. Knowing when that move pays for itself, and when it just adds a layer you have to read through later, is what separates a codebase that’s a pleasure to change from one that’s a maze of one-line wrappers.
Extract a custom hook when any one of these three conditions holds.
Reuse. The behavior shows up in two or more components, and the wiring is non-trivial, 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, about to be copied to a fourth. Extraction stops the copying and gives the behavior a single home to fix bugs in.
Clarity. A single component holds a tangled knot of coordinated hooks: an effect, some state, and a ref all working together toward one job. Lifting that knot into a named hook reveals what the component is actually doing, even if this is the only component that will ever call it. Reuse is not a prerequisite here; readability is reason enough on its own. A 120-line component with three intertwined effects becomes legible the moment two of them move behind useAutosave() and usePresence().
Encapsulation. A behavior wraps an external system, such as a browser API or a third-party SDK, anything that needs effect plus state plus ref wiring and a cleanup function, and that machinery should be hidden behind a clean interface. useMediaQuery hides a matchMedia subscription, useIntersection hides an IntersectionObserver, and anything wrapping addEventListener fits here. The component shouldn’t have to know the external API exists; it should just ask a question and get an answer.
The inverse matters just as much, because over-extraction is its own kind of mess, and it’s the one juniors fall into while thinking they’re being tidy.
Do not extract when there’s nothing to reveal. A single useState(false) does not become better by being renamed useToggle and called once; you’ve added a file, an import, and a layer of indirection to hide a single line that was already clear. Reaching for a hook every time is the same mistake as premature optimization: effort spent buying a benefit that isn’t there. And do not extract a function that calls no hooks. That’s not a hook at all, it’s a utility, and it belongs in lib/ without the use prefix, exactly as the first section warned.
One more case lives right on this boundary, and it gets its own section next: a hook that takes a callback as an argument and calls it from inside an effect. That pattern is worth extracting, but it carries a subtle pitfall, and handling it is the difference between a hook that works and one that thrashes.
The walkthrough below follows the question order an experienced engineer runs through, fastest 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.
Walking the questions in order makes “leave it inline” and “it’s just a utility” feel like real answers, the ones you reach by asking the right questions in the right order, rather than failures to extract. The order is what makes it work. You ask whether it calls hooks first, because that’s the cheapest cut. Then come the reuse and clarity questions, with “leave it inline” as the floor underneath them. The encapsulation question is the one you can ask up front the moment you see a browser API, because wiring addEventListener or a third-party widget is almost always worth hiding, even before you know whether you’ll reuse it.
One composition pattern carries a subtle pitfall, and a hook called useOnClickOutside is the clearest place to see it.
The behavior is the dropdown-and-modal staple: close when the user clicks anywhere outside this element. So you write useOnClickOutside(ref, handler). It attaches a pointerdown listener in an effect, and when a click lands outside the element ref points at, it calls handler. The call site looks like this, and it’s exactly as ergonomic as it should be:
useOnClickOutside(menuRef, () => setOpen(false));That handler is a fresh inline arrow, written right at the call site and recreated on every render. That’s the right way for a caller to use this hook: nobody should have to wrap it in anything or think about its identity. Pushing that ceremony onto every caller would make the hook miserable to use.
That convenience creates a problem inside the hook. The naive implementation puts handler in the effect’s dependency array, because the effect closes over it and the exhaustive-deps rule asks for everything the effect reads. Since the caller passes a brand-new arrow every render, handler’s identity changes every render, so the effect tears down and re-attaches its listener every render. That’s wasteful, and worse: there’s a window between removing the old listener and adding the new one where a click can fall through and be missed entirely.
The fix is the technique you learned a couple of chapters back, applied inside the hook. Wrap the caller’s handler with useEffectEvent . The wrapped version always calls the latest handler the caller passed, but it has a stable identity, so the effect can depend on ref alone and never on the handler. The listener attaches once and stays put. The caller still passes any inline arrow they like, and now it costs nothing.
Step through the implementation below. The connection between the wrap line and the dependency array is the point to watch.
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 signature. The hook takes a ref and a plain handler. Consumers pass an inline arrow; they 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, but its identity is stable 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. The listener checks whether the click landed outside the element before firing.
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’s correctly left out, so the listener attaches once and never re-attaches on re-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]);};Cleanup removes the listener when the component unmounts or the ref changes. Standard effect hygiene.
This is the pattern that makes any callback-accepting hook feel right, and it generalizes. Whenever a hook takes a function from its caller and calls that function from inside an effect, wrap it with useEffectEvent so the effect doesn’t 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. This isn’t an advanced technique you graduate to; it’s the everyday way you’ll work, and it falls straight out of the definition. A custom hook is a function that may call hooks, and “hooks” includes the ones you wrote yourself. That’s the payoff of the use contract: composition comes for free.
Two quick examples show the shape. Read them as illustrations, not implementations to memorize.
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 composes a built-in concurrent hook into a domain behavior. It takes useDeferredValue, the hook that lets an expensive update lag behind without blocking typing, and wraps it into “give me the filtered list, kept responsive.” The component calling useFilteredList(items, query) never has to know useDeferredValue is involved. usePaginatedData composes a data-fetching hook with useSearchParams, the Next.js hook that reads the current page out of the URL’s query string. You’ll meet useSearchParams properly when the course reaches the App Router; here it’s just another hook being composed in.
The rule for composition is short: compose freely, and flatten only when nesting hides intent. Stacking hooks inside hooks is fine right up until you can no longer tell where a given piece of state actually lives, the point where you’re three hooks deep chasing which layer owns the value you’re debugging. That’s the signal to inline one layer back. Until then, let composition do its job.
There’s a set of custom hooks that a 2026 SaaS codebase reaches for over and over. The value in knowing them is not memorizing implementations. It’s recognizing 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 to see how everything in this lesson comes together, 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 makes a good flagship because it exercises everything you’ve just learned: it’s generic, it returns a tuple, and it has one detail that experienced engineers get right and the obvious implementation gets wrong.
That detail is server rendering. The tempting version is useState(() => localStorage.getItem(key)), and it has two problems. On the server, during SSR, there is no localStorage because window doesn’t exist, so that line throws and the render crashes. And even if you guard against that, the server and the first client render would still disagree about the value, so React would flag 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.
Step through the implementation. 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).
Read back what each piece bought. The generic means a draft string and a saved count share one definition. The tuple means the call site destructures it like useState. The three store functions mean the hook is safe on the server, reactive to other tabs, and never throws on bad data. The cached snapshot keeps an object or array value referentially stable, so useSyncExternalStore doesn’t spin. This one hook touches nearly everything in the lesson, and a project later in the course will hand it to you to build, so you’ll see it again.
The following 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.
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.
The thread under all seven is recognition, not recall. You don’t need these memorized line for line. What you need is to catch yourself about to wire matchMedia by hand and stop, because there’s a named hook, yours or a library’s, that already encapsulates it correctly, cleanup and all.
Most custom hooks should not take a dependency array. The experienced default is a focused API: useDebounced(value, delay) takes a value, not a list of dependencies, and that’s deliberate. The caller passes the thing, and the hook owns the wiring. Value-shaped hooks like that are easier to use correctly and have one fewer way to go wrong.
But occasionally a hook does accept a dependency array, something shaped like useInterval(callback, deps), where the caller controls when the interval resets. That hook has a problem the built-ins don’t: the exhaustive-deps lint rule won’t check its dependency array, because the rule only knows about React’s own hooks. Your custom dep-array hook can drift out of sync with its dependencies and the linter says nothing.
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 custom hooks whose final argument is a dependency array, and from then on the lint enforces exhaustive deps on them exactly as it does on useEffect. You met the react-hooks rules a couple of chapters back; this is the seam where you extend them to your own hooks.
The better instinct is to avoid needing this at all. Whenever you can express a hook as value-shaped, like useDebounced(value, delay), rather than dep-array-shaped, do so: there’s no additionalHooks upkeep and no dependency array for a caller to get 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.
Now do the core motion yourself. The component below shows an online/offline status badge with its logic wired inline: navigator.onLine read into state, and an effect subscribing to the online and offline window events, all tangled in among the JSX. This is the encapsulation case, a browser API with subscribe-and-cleanup wiring that belongs behind a clean interface.
Extract that logic into a useOnlineStatus() hook defined in the same file, returning a boolean, and have App consume it. The rendered output should stay 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, since the whole skill is in the doing.
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> );}The component now reads as a single question, am I online?, with the wiring living behind the name. That’s the extraction, end to end.
One last sort to harden the central decision. Drag each snippet into where it belongs: extract a custom hook, ship it from lib/ as a plain utility, or leave it inline in the component. The line between the three is the judgment this whole lesson has been about.
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 handlerYou can look at a block of hook wiring and decide whether it’s worth a name: reuse, clarity, or encapsulation tips it toward extraction, a single trivial line keeps it inline, and no hooks at all sends it to lib/. You can name a hook so the use prefix tells the truth, shape its return as a value, tuple, or object and hold that shape, and make it generic when a type should flow from the call site. You can wrap a caller’s callback with useEffectEvent so a callback-accepting hook doesn’t thrash, compose hooks out of other hooks, and recognize the 2026 catalog on sight. The next lesson turns to the React Compiler, the piece that quietly auto-memoizes all of this 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.