Reusing logic with custom hooks
Move repeated state and Effects out of components into custom hooks: extract one step by step, pass values between hooks, and know when not to.
- 01Extract a custom hookMove repeated state and Effects into a function named
use…, return what components need, and call it from each one. - 02Know what a hook sharesExplain why two components that call the same hook get separate state, and pass one hook's result into the next.
- 03Write hooks that stay correctName hooks only when they call hooks, wrap callbacks in an Effect Event, and avoid lifecycle wrappers like
useMount.
Two components with the same fifteen lines
A shop should tell people when their network drops, so nobody pays for an order that never arrives. This cart page does it in two places: a banner at the top, and a Checkout button that waits for the network. Each component keeps its own copy of the online status, with the state and Effect you know from Synchronizing with effects. The NetworkButtons.jsx tab adds two buttons that fire the same offline and online events the browser fires when your network drops and comes back. Click Go offline, then Go online.
import { useEffect, useState } from "react";import NetworkButtons from "./NetworkButtons.jsx"; function OfflineBanner() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; }, []); if (isOnline) return null; return <p><strong>You're offline.</strong> Your cart is saved on this device.</p>;} function CheckoutButton() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", handleOffline); }; }, []); return <button disabled={!isOnline}>{isOnline ? "Checkout" : "Waiting for the network..."}</button>;} export default function App() { return ( <> <OfflineBanner /> <h3>Your cart: Tea, Cake</h3> <CheckoutButton /> <NetworkButtons /> </> );}In OfflineBanner, line 5 keeps isOnline in state, starting as true. The Effect on lines 6 to 19 listens for the window's online and offline events (lines 13 and 14), and its handlers on lines 7 to 12 set the state. The cleanup on lines 15 to 18 removes both listeners. Line 21 shows nothing while the page is online.
CheckoutButton repeats the same fifteen lines, 26 to 40, and line 42 turns the button off while the page is offline. Click Go offline: the banner appears and the button says Waiting for the network.... Both work. But a fix in one copy has to be made in the other, and a third component, like a "Save for later" button, would need a third copy.
React lets you give this logic a name and write it once. A custom hook is your own function that calls React's hooks, like useState and useEffect. Here it would be useOnlineStatus(), and each component would call it in one line:
useOnlineStatus, and each component calls it in one line.A café keeps one recipe card for its house sauce. Every cook follows the same card, but each cooks at their own stove, in their own pot. When the chef improves the recipe, they change one card, and every cook's next batch is better.
- In real life: The recipe card for the house sauce
- In React: The custom hook's code, like
useOnlineStatus - In real life: Each cook follows the card at their own stove
- In React: Each component calls the hook while it renders
- In real life: Each cook ends up with their own pot
- In React: Each call gets its own state and Effects
- In real life: Fix the card once, and every cook's next pot changes
- In React: Fix the hook once, and every component gets the fix
A custom hook is a function whose name starts with use and that calls other hooks. It lets components share logic, like "listen for the network". Each component that calls it still gets its own state and its own Effects.
Extract a custom hook, step by step
Moving the repeated lines into a hook takes five steps. Nothing about what the page does changes; only where the code lives.
- Name a function
useplus a capital letter:useOnlineStatus. The name tells you, and React's linter, that this function calls hooks, so the rules of hooks apply to it. - Move the state and the Effect into it, as they are. Lines 5 to 19 of
OfflineBannerbecome lines 4 to 18 of the hook. - Return what the component needs to draw. Here that's one value,
isOnline. A hook can return anything: a value, an array likeuseStatedoes, or an object. - Call it at the top level of each component, in place of the lines you moved:
const isOnline = useOnlineStatus();. - Put it in its own file and export it, so any component can import it.
Here is the hook. It runs in the live example below, as the useOnlineStatus.js tab:
import { useEffect, useState } from "react";
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}Line 3 names the hook and exports it. Line 4 is the state, and lines 5 to 18 are the Effect, unchanged, character for character. Line 19 returns isOnline, and that's the only new line. And here are the two components, now calling it:
import { useOnlineStatus } from "./useOnlineStatus.js";import NetworkButtons from "./NetworkButtons.jsx"; function OfflineBanner() { const isOnline = useOnlineStatus(); if (isOnline) return null; return <p><strong>You're offline.</strong> Your cart is saved on this device.</p>;} function CheckoutButton() { const isOnline = useOnlineStatus(); return <button disabled={!isOnline}>{isOnline ? "Checkout" : "Waiting for the network..."}</button>;} export default function App() { return ( <> <OfflineBanner /> <h3>Your cart: Tea, Cake</h3> <CheckoutButton /> <NetworkButtons /> </> );}Line 1 imports the hook. Line 5 and line 11 call it, and each component is back to saying only what it shows. Click Go offline and Go online: the page behaves exactly as before. Reading OfflineBanner now, you see what it needs, the online status, not how the browser reports it.
A custom hook isn't a special React feature. It's a plain JavaScript function. When OfflineBanner calls useOnlineStatus(), the useState and useEffect inside run as part of OfflineBanner's render, exactly as if they were still written in it. React can't even tell the difference.
Hooks share logic, not state
Both components show the same online status, so it's easy to think they share one isOnline. They don't. Each call to useOnlineStatus() made its own state and its own Effect, and both happen to agree because both listen to the same browser events. A hook that keeps state the user changes makes this easy to see. This one keeps a value in state and copies it into localStorage, so the page remembers it:
import { useEffect, useState } from "react";
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key);
return saved === null ? initialValue : JSON.parse(saved);
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}Lines 4 to 7 make the state. Passing a function to useState means React calls it only for the first render: line 5 reads the saved text, and line 6 uses it, turned back into a value with JSON.parse, or initialValue when nothing is saved yet. The Effect on lines 9 to 11 saves every new value. Line 13 returns the value and its setter, like useState does.
A shop page uses it twice: the header reads the theme, and a picker switches it. Both call useLocalStorage("theme", "light"). Click Switch theme, and read both lines.
import { useLocalStorage } from "./useLocalStorage.js"; function ShopHeader() { const [theme] = useLocalStorage("theme", "light"); return <h3>Shop header, drawn in the {theme} theme</h3>;} function ThemePicker() { const [theme, setTheme] = useLocalStorage("theme", "light"); return ( <p> Picker: <strong>{theme}</strong>{" "} <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Switch theme</button> </p> );} export default function App() { return ( <> <ShopHeader /> <ThemePicker /> </> );}The picker says dark, but the header still says light. Line 4 and line 9 are two calls, so they made two separate theme states. Line 13 changed only the picker's, and the picker's Effect saved "dark" in localStorage. Nothing tells the header. Now press Run again above the result: both components start over, both read localStorage, and both say dark.
useLocalStorage made its own theme state. The picker's says dark and saved it to localStorage. The header's still says light, because nothing tells it.That's the rule: custom hooks share logic, not state. Each component that calls a hook gets a fresh copy of everything the hook keeps, like each cook's own pot. To make two components show one theme, keep one state and pass it down, as in Sharing state between components: call the hook once, in App, and give both components the theme as a prop. Try both ways in the playground. Click Switch theme, then Run again, in each version.
import { useLocalStorage } from "./useLocalStorage.js"; function ShopHeader() { const [theme] = useLocalStorage("theme", "light"); return <h3>Shop header, drawn in the {theme} theme</h3>;} function ThemePicker() { const [theme, setTheme] = useLocalStorage("theme", "light"); return ( <p> Picker: <strong>{theme}</strong>{" "} <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Switch theme</button> </p> );} export default function App() { return ( <> <ShopHeader /> <ThemePicker /> </> );}localStorage: emptyLoading the page...
Two components, two calls to useLocalStorage("theme", "light"). Press Switch theme and watch the header.
With each component calling the hook, the header falls behind after every switch, and catches up when the page starts again. With one call in App, there's one theme, and both parts change together. (When many components far apart need the same outside data, passing it down gets tiring. A store that lives outside React solves that; it's the subject of the next lesson, Subscribing to external stores.)
How to read the table: each row asks one question about the theme page, answered for both versions.
| Question | Each component calls the hook | App calls it once and passes it down |
|---|---|---|
How many theme states are there? | Two: each call to useLocalStorage makes its own useState. | One, made by the single call in App. |
| Does Switch theme change the header? | No. Only the picker's state changes. The header catches up when both start again and read localStorage. | Yes. App re-renders and passes the new theme to both. |
| How often does the hook's code run? | Once per component, on every render of that component. | Once, on every render of App. |
Pass values from one hook to the next
Because a hook is a function, you can give it arguments, and pass what one hook returns into another. A shop search shows how. It shouldn't ask the server on every key: typing tea would send three searches. So the search waits until you stop typing for a moment, which is called debouncing. Here are two hooks, one for each job:
import { useEffect, useState } from "react";
import { searchProducts } from "./api.js";
export function useDebouncedValue(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
export function useSearchResults(query) {
const [results, setResults] = useState([]);
useEffect(() => {
let ignore = false;
searchProducts(query).then((found) => {
if (!ignore) setResults(found);
});
return () => {
ignore = true;
};
}, [query]);
return results;
}useDebouncedValue (lines 4 to 11) keeps its own copy of a value. Each time the value changes, its Effect starts a timer on line 7 that copies it after delay milliseconds. If the value changes first, the cleanup on line 8 cancels the old timer, so only a value that stays put for the whole delay gets through.
useSearchResults (lines 13 to 25) asks the pretend server in api.js for the products matching query. It's the search with a cleanup from You might not need an effect: line 16 starts with ignore false, the cleanup on line 21 sets it to true, and line 18 only shows answers for the latest query. Now the page that uses both. Type tea quickly and watch the console under the result.
import { useState } from "react";import { useDebouncedValue, useSearchResults } from "./hooks.js"; export default function App() { const [query, setQuery] = useState(""); const debouncedQuery = useDebouncedValue(query, 400); const results = useSearchResults(debouncedQuery); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search the shop" /> <p>Results for "{debouncedQuery}":</p> <ul> {results.map((name) => ( <li key={name}>{name}</li> ))} </ul> </> );}Line 5 keeps what you type. Line 6 passes query to useDebouncedValue, with a delay of 400 ms. Line 7 passes what that returns, debouncedQuery, to useSearchResults. The console shows Search: "" when the page starts (twice, because StrictMode runs Effects twice in development), and then only Search: "tea": one search, 400 ms after your last key. At that moment line 12 changes to Results for "tea":, and 300 ms later the list shows Green tea, Black tea, and Tea cups.
App passes query into useDebouncedValue, and what it returns into useSearchResults. When you stop typing, the new value flows down the chain.You didn't connect the two hooks with events or callbacks. The code of a custom hook runs every time its component renders, so on every render App calls both hooks again with the newest values, the way you'd call two functions. When debouncedQuery changes, useSearchResults receives the new value, and its Effect, which lists query, runs again.
Because a hook runs during every render, the code in its body has the same rules as a component's body: keep it pure, and do anything that touches the outside world in an Effect or an event handler. Its Effects follow their dependencies, exactly as in a component.
A hook that takes a callback
Some hooks need to call your code back: when a timer fires, when a message arrives. Here is useInterval, a hook that calls onTick every delay milliseconds. It logs when it starts and stops its timer, so you can see it work:
import { useEffect } from "react";
export function useInterval(onTick, delay) {
useEffect(() => {
console.log("Start the timer");
const id = setInterval(onTick, delay);
return () => {
console.log("Stop the timer");
clearInterval(id);
};
}, [onTick, delay]);
}Line 6 starts the timer with setInterval, and the cleanup on lines 7 to 10 stops it. Line 11 lists both values the Effect reads, as the linter asks: onTick and delay. A note box uses it to save your note every 2 seconds. Type a word slowly, about one letter a second, and watch the console.
import { useState } from "react";import { useInterval } from "./useInterval.js"; export default function App() { const [note, setNote] = useState(""); const [saved, setSaved] = useState(""); useInterval(() => { if (note !== saved) { setSaved(note); console.log(`Saved "${note}"`); } }, 2000); return ( <> <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note for the courier" /> <p>Last saved: "{saved}"</p> </> );}Lines 8 to 13 pass useInterval a function that saves the note if it changed. Every letter you type logs Stop the timer and Start the timer, and nothing saves until you stop typing for 2 seconds. Here's why. Each letter changes note, so App renders again, and line 8 creates a new arrow function. onTick is a dependency, and it changed, so the Effect stops the timer and starts a new one, from zero. While you type, the 2 seconds never pass.
App passes a new onTick after every render. Listed as a dependency, it restarts the timer each time. Wrapped in an Effect Event, it isn't a dependency, and tick() runs the newest one.The hook only needs to read the latest onTick when the timer fires. A new callback is no reason to restart. That's the job of an Effect Event, from Separating events from effects. Wrap the callback in useEffectEvent inside the hook:
import { useEffect, useEffectEvent } from "react";
export function useInterval(onTick, delay) {
const tick = useEffectEvent(onTick);
useEffect(() => {
console.log("Start the timer");
const id = setInterval(() => tick(), delay);
return () => {
console.log("Stop the timer");
clearInterval(id);
};
}, [delay]);
}Line 4 wraps onTick in an Effect Event called tick. Line 7 calls tick() when the timer fires, and it always runs the newest callback, with the newest note. Line 12 lists only delay. Here is the same note box with this useInterval.js. Type slowly again.
import { useState } from "react";import { useInterval } from "./useInterval.js"; export default function App() { const [note, setNote] = useState(""); const [saved, setSaved] = useState(""); useInterval(() => { if (note !== saved) { setSaved(note); console.log(`Saved "${note}"`); } }, 2000); return ( <> <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note for the courier" /> <p>Last saved: "{saved}"</p> </> );}The console shows Start the timer only when the page starts, and then a Saved line every 2 seconds while your note keeps changing. App didn't change at all: the fix lives in the hook, and every component that calls useInterval gets it. Code written before React 19.2 often does the same with a ref that holds the latest callback; useEffectEvent is the way to do it now.
Keep the Effect Event inside the hook, next to its Effect. The component passes a plain function, like onTick, and the hook wraps it. Never pass an Effect Event out of a hook, or into one.
Names, and when a hook is worth writing
A hook's name follows two rules. It starts with use, then a capital letter: useOnlineStatus, not useonlineStatus. And only functions that call hooks get it:
// No hooks inside: a plain function, named for what it returns.
function sortByPrice(products) {
return [...products].sort((a, b) => a.price - b.price);
}
// Calls useState and useEffect: a hook, so its name starts with use.
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const update = () => setWidth(window.innerWidth);
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
return width;
}sortByPrice on lines 2 to 4 only calculates, so it has a plain name, and you can call it anywhere, even in a loop or an if. useWindowWidth on lines 7 to 15 calls useState and useEffect, so it is a hook. The use prefix is a promise to readers and tools: React's linter checks the rules of hooks inside any function named use…, and wherever it's called. Naming sortByPrice useSortedProducts would forbid calling it in an if for no reason; leaving use off useWindowWidth would hide its rules.
Name a hook for what it does, not for when it runs. A common first try is a "lifecycle" hook, like useMount, which runs code once when a component appears. Here a product page uses one to log each visit. Click Cake and watch the console.
import { useEffect, useState } from "react";import { logVisit } from "./analytics.js"; function useMount(callback) { useEffect(() => { callback(); }, []);} function ProductPage({ productId }) { useMount(() => { logVisit(productId); }); return <h3>Product: {productId}</h3>;} export default function App() { const [productId, setProductId] = useState("tea"); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <ProductPage productId={productId} /> </> );}The console shows Visit: tea when the page starts (twice in development), and then nothing when you click Cake, though the heading says Product: cake. Line 7 gives the Effect an empty array, so it runs only when ProductPage appears. The callback on lines 11 to 13 reads productId, but nothing tells the Effect: the linter flags callback missing from line 7, and people silence it, and at the call site the linter can't see the Effect at all. A hook named for its purpose keeps the dependency where the linter can check it:
import { useEffect, useState } from "react";import { logVisit } from "./analytics.js"; function usePageVisit(productId) { useEffect(() => { logVisit(productId); }, [productId]);} function ProductPage({ productId }) { usePageVisit(productId); return <h3>Product: {productId}</h3>;} export default function App() { const [productId, setProductId] = useState("tea"); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <ProductPage productId={productId} /> </> );}usePageVisit on lines 4 to 8 takes productId and lists it on line 7. Click Cake: the console shows Visit: cake. Wrappers like useMount and useEffectOnce make you think in "when", and Effects work by "what to keep in sync", as in The lifecycle of an effect. Good hooks say what they keep in sync: useOnlineStatus, usePageVisit(productId), useChatRoom(roomId).
How to read the table: each row is something you might turn into a hook, whether to, and why.
| What you have | Write a custom hook? | Why |
|---|---|---|
| The same state and Effect in two components, like the online status | Yes. | One name, one place to fix bugs, and components that read like what they do. |
| A function that only calculates, like sorting by price | No. Write a plain function. | It calls no hooks, so it can run anywhere, even in a loop or an if. |
An Effect you want to run once, wrapped as useMount | No. | It hides what the Effect reads, so a changed value is ignored. Write a hook named for its purpose, with real dependencies. |
| One Effect in one component, with a clear job | Often, yes. | useChatConnection(roomId) says more than ten lines of Effect, and makes the Effect easy to change later. |
A search page built from small hooks
In a real app, hooks you wrote for one page become building blocks for the next. This search page uses every hook from this lesson, unchanged, from one hooks.js. It remembers your last search across visits and warns you when you go offline. Type cake, press Run again, and click Go offline.
import { useDebouncedValue, useLocalStorage, useOnlineStatus, useSearchResults } from "./hooks.js";import NetworkButtons from "./NetworkButtons.jsx"; export default function App() { const isOnline = useOnlineStatus(); const [query, setQuery] = useLocalStorage("last-search", ""); const debouncedQuery = useDebouncedValue(query, 400); const results = useSearchResults(debouncedQuery); return ( <> {!isOnline && <p><strong>You're offline.</strong> These results may be old.</p>} <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search the shop" /> <ul> {results.map((name) => ( <li key={name}>{name}</li> ))} </ul> <NetworkButtons /> </> );}Lines 5 to 8 read like a description of the page: know whether we're online, remember the last search, wait until typing stops, and fetch the results. After Run again, the box still says cake, because line 6 keeps the search in localStorage, and the console shows Search: "cake" straight away (twice, in development). Line 12 shows the banner while you're offline. Every hook keeps its own state for this page, and none of them knows about the others: line 7 passes line 6's value on, and line 8 passes line 7's.
function formatPrice(amount) { return "$" + amount.toFixed(2); }function useOnlineStatus() { const [isOnline, setIsOnline] = useState(true); /* … */ return isOnline; }function sortByPrice(products) { return [...products].sort((a, b) => a.price - b.price); }function useTheme() { return useContext(ThemeContext); }function isValidEmail(text) { return text.includes("@"); }function useDebouncedValue(value, delay) { const [debounced, setDebounced] = useState(value); /* … */ }function cartTotal(items) { return items.reduce((sum, item) => sum + item.price, 0); }function usePageVisit(productId) { useEffect(() => { logVisit(productId); }, [productId]); }
Sort each function: does it call hooks (so its name must start with use), or is it a plain function?
Common mix-ups
- "Two components that call the same hook share its state." No. Each call makes its own state and Effects. In the theme page, the picker switched to dark and the header stayed light.
- "A custom hook is a special kind of React object." No. It's a plain function that calls hooks. React sees the hooks inside as if the component called them itself.
- "A custom hook runs once, like a setup step." No. Its code runs on every render of the component that calls it. Only its Effects skip runs, by their dependencies.
- "Every helper a component uses should start with use." No. Only functions that call hooks.
sortByPriceandformatPriceare plain functions. - "useMount makes Effects easier to read." No. It hides what the Effect reads from the linter, so a new
productIdwas never logged. Name hooks for what they keep in sync. - "A callback a hook receives must be in its dependency array." Only if a new callback should restart the Effect. Usually it shouldn't: wrap it in
useEffectEventinside the hook.
Practice exercises
Each exercise shows its result under the code, with the console under it. Press Edit & run to change the code, and the result runs again as you type. Make a guess first, then use the hints.
Predict first: you click +1 Tea twice. What does the Cake line show? Then click in the result to check.
import { useState } from "react"; function useQuantity(start) { const [quantity, setQuantity] = useState(start); return [quantity, () => setQuantity(quantity + 1)];} function ProductCard({ name }) { const [quantity, addOne] = useQuantity(1); return ( <p> {name}: {quantity} <button onClick={addOne}>+1 {name}</button> </p> );} export default function App() { return ( <> <ProductCard name="Tea" /> <ProductCard name="Cake" /> </> );}import { useState } from "react"; function useQuantity(start) { const [quantity, setQuantity] = useState(start); return [quantity, () => setQuantity(quantity + 1)];} function ProductCard({ name }) { const [quantity, addOne] = useQuantity(1); return ( <p> {name}: {quantity} <button onClick={addOne}>+1 {name}</button> </p> );} export default function App() { return ( <> <ProductCard name="Tea" /> <ProductCard name="Cake" /> </> );}useQuantity is called once per ProductCard, so each card has its own quantity. Two clicks make Tea 3; Cake still shows Cake: 1.
Write a useToggle hook and use it in both components. Then click Show Tea details. What does the shipping button say?
import { useState } from "react"; function ProductDetails({ name, details }) { const [open, setOpen] = useState(false); function toggle() { setOpen(!open); } return ( <div> <button onClick={toggle}>{open ? "Hide" : "Show"} {name} details</button> {open && <p>{details}</p>} </div> );} function ShippingInfo() { const [open, setOpen] = useState(false); function toggle() { setOpen(!open); } return ( <div> <button onClick={toggle}>{open ? "Hide" : "Show"} shipping</button> {open && <p>Free shipping on orders over $20.</p>} </div> );} export default function App() { return ( <> <ProductDetails name="Tea" details="Green tea from Assam, 100 g." /> <ShippingInfo /> </> );}import { useState } from "react"; function useToggle(initialValue) { const [open, setOpen] = useState(initialValue); function toggle() { setOpen(!open); } return [open, toggle];} function ProductDetails({ name, details }) { const [open, toggle] = useToggle(false); return ( <div> <button onClick={toggle}>{open ? "Hide" : "Show"} {name} details</button> {open && <p>{details}</p>} </div> );} function ShippingInfo() { const [open, toggle] = useToggle(false); return ( <div> <button onClick={toggle}>{open ? "Hide" : "Show"} shipping</button> {open && <p>Free shipping on orders over $20.</p>} </div> );} export default function App() { return ( <> <ProductDetails name="Tea" details="Green tea from Assam, 100 g." /> <ShippingInfo /> </> );}useToggle holds the state and the toggle function, and returns both in an array. Each component calls it, so each has its own open: opening Tea's details leaves the shipping button at Show shipping.
Make the page log a visit for every product it shows. After your fix, click Cake. What does the console print?
import { useEffect, useState } from "react";import { logVisit } from "./analytics.js"; function useMount(callback) { useEffect(() => { callback(); }, []);} function ProductPage({ productId }) { useMount(() => { logVisit(productId); }); return <h3>Product: {productId}</h3>;} export default function App() { const [productId, setProductId] = useState("tea"); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <ProductPage productId={productId} /> </> );}import { useEffect, useState } from "react";import { logVisit } from "./analytics.js"; function usePageVisit(productId) { useEffect(() => { logVisit(productId); }, [productId]);} function ProductPage({ productId }) { usePageVisit(productId); return <h3>Product: {productId}</h3>;} export default function App() { const [productId, setProductId] = useState("tea"); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <ProductPage productId={productId} /> </> );}usePageVisit(productId) puts the value the Effect reads in its dependency array, so a new product is a new visit. Clicking Cake logs Visit: cake.
This search asks the server on every key. Make it wait until typing stops, using the hook in the hooks.js tab. With your fix, type tea quickly. Which search does the console print after the empty ones?
import { useState } from "react";import { useSearchResults } from "./hooks.js"; export default function App() { const [query, setQuery] = useState(""); const results = useSearchResults(query); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search the shop" /> <ul> {results.map((name) => ( <li key={name}>{name}</li> ))} </ul> </> );}import { useState } from "react";import { useDebouncedValue, useSearchResults } from "./hooks.js"; export default function App() { const [query, setQuery] = useState(""); const debouncedQuery = useDebouncedValue(query, 400); const results = useSearchResults(debouncedQuery); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search the shop" /> <ul> {results.map((name) => ( <li key={name}>{name}</li> ))} </ul> </> );}The starter searched for t, te, and tea. With the debounced value in between, typing quickly sends one search, Search: "tea", 400 ms after the last key.
Make the header and the picker always agree. After your fix, press Switch theme once (press Run again first if the page starts dark). Which theme does the header say?
import { useLocalStorage } from "./useLocalStorage.js"; function ShopHeader() { const [theme] = useLocalStorage("theme", "light"); return <h3>Shop header, drawn in the {theme} theme</h3>;} function ThemePicker() { const [theme, setTheme] = useLocalStorage("theme", "light"); return ( <p> Picker: <strong>{theme}</strong>{" "} <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Switch theme</button> </p> );} export default function App() { return ( <> <ShopHeader /> <ThemePicker /> </> );}import { useLocalStorage } from "./useLocalStorage.js"; function ShopHeader({ theme }) { return <h3>Shop header, drawn in the {theme} theme</h3>;} function ThemePicker({ theme, onSwitch }) { return ( <p> Picker: <strong>{theme}</strong>{" "} <button onClick={onSwitch}>Switch theme</button> </p> );} export default function App() { const [theme, setTheme] = useLocalStorage("theme", "light"); return ( <> <ShopHeader theme={theme} /> <ThemePicker theme={theme} onSwitch={() => setTheme(theme === "light" ? "dark" : "light")} /> </> );}With one call in App, there is one theme. App passes it to both components, and the picker's button calls onSwitch, which sets it. Switch theme now changes the header too: Shop header, drawn in the dark theme.
Type Tea in the starter, one letter at a time. How many new Start the timer lines appear? Then change useInterval.js so typing never restarts the timer.
import { useState } from "react";import { useInterval } from "./useInterval.js"; export default function App() { const [note, setNote] = useState(""); const [saved, setSaved] = useState(""); useInterval(() => { if (note !== saved) { setSaved(note); console.log(`Saved "${note}"`); } }, 2000); return ( <> <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note for the courier" /> <p>Last saved: "{saved}"</p> </> );}import { useState } from "react";import { useInterval } from "./useInterval.js"; export default function App() { const [note, setNote] = useState(""); const [saved, setSaved] = useState(""); useInterval(() => { if (note !== saved) { setSaved(note); console.log(`Saved "${note}"`); } }, 2000); return ( <> <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note for the courier" /> <p>Last saved: "{saved}"</p> </> );}Each letter rendered App, which passed a new onTick, so the starter logged Stop the timer and Start the timer three times for Tea. Wrapped in useEffectEvent, the callback isn't a dependency: the timer starts once and saves while you type.
Check your understanding
Question 1 of 7Two components each call
useOnlineStatus(). What do they share?Choose an answer to see the explanation.
Question 2 of 7You click Add tea twice. What does the cart badge show?
Read the code, then predictimport { useState } from "react"; function useCartCount() { const [count, setCount] = useState(0); return [count, () => setCount(count + 1)]; } function CartBadge() { const [count] = useCartCount(); return <p>Cart: {count}</p>; } function AddTea() { const [, add] = useCartCount(); return <button onClick={add}>Add tea</button>; } export default function App() { return ( <> <CartBadge /> <AddTea /> </> ); }Choose an answer to see the explanation.
Question 3 of 7This function calls no hooks. Which name should replace
___?Read the code, then predictJavaScriptfunction ___(products) { return [...products].sort((a, b) => a.price - b.price); }Choose an answer to see the explanation.
Question 4 of 7When does the code inside a custom hook run?
Choose an answer to see the explanation.
Question 5 of 7
ProductPageshows the tea page, and you then switch it to the cake page. What doeslogVisitlog for cake?Read the code, then predictJSXfunction useMount(callback) { useEffect(() => { callback(); }, []); } function ProductPage({ productId }) { useMount(() => logVisit(productId)); return <h3>Product: {productId}</h3>; }Choose an answer to see the explanation.
Question 6 of 7
AppcallsuseInterval(() => { … }, 2000)with theuseIntervalshown here. You type one letter every second. When does the note save?Read the code, then predictJSXimport { useEffect } from "react"; export function useInterval(onTick, delay) { useEffect(() => { console.log("Start the timer"); const id = setInterval(onTick, delay); return () => { console.log("Stop the timer"); clearInterval(id); }; }, [onTick, delay]); } // In App.jsx: useInterval(() => { if (note !== saved) { setSaved(note); console.log(`Saved "${note}"`); } }, 2000);Choose an answer to see the explanation.
Question 7 of 7Why does
useIntervalwraponTickinuseEffectEventinstead of listing it as a dependency?Choose an answer to see the explanation.
Key takeaways
- A custom hook is a function whose name starts with
useand a capital letter, and that calls other hooks. - To extract one, move the state and Effects into it unchanged, return what components need, and call it at the top level.
- Hooks share logic, not state. Each call gets its own state and Effects; to share state, call the hook once and pass it down.
- A hook's code runs on every render, so you can pass one hook's result into the next, and its body must stay pure.
- When a hook takes a callback for its Effect, wrap it in
useEffectEventinside the hook. - Give
useonly to functions that call hooks, and name hooks for what they keep in sync, neveruseMount.
A custom hook shares the recipe, not the pot.
Write the logic once in a use… function; every component that calls it gets its own state.
Coming next: Subscribing to external stores. It shows how components read data that lives outside React, like the network status or a shared store, with useSyncExternalStore.