cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Extract a custom hookMove repeated state and Effects into a function named use…, return what components need, and call it from each one.
  • 02
    Know what a hook sharesExplain why two components that call the same hook get separate state, and pass one hook's result into the next.
  • 03
    Write 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.

The online status, copied into two componentsPop out in the code editor (opens in a new tab)JSX
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 />    </>  );}
ResultLoading…

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:

Two halves. Left, before: two windows, OfflineBanner and CheckoutButton, each holding its own copy of the same card, state + Effect. Right, after: one function card, useOnlineStatus, holding state + Effect, with arrows from two cards below it, OfflineBanner and CheckoutButton, that call it.Before: copiedOfflineBannerstate + EffectCheckoutButtonstate + Effectthe same lines, twiceAfter: one hookuseOnlineStatus()state + Effect, onceOfflineBannerCheckoutButtoneach calls it in one line
Before, the same state and Effect are written out in both components. After, they live once in useOnlineStatus, and each component calls it in one line.
Real-life analogyA recipe card in a busy kitchen

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
The main idea

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.

  1. Name a function use plus 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.
  2. Move the state and the Effect into it, as they are. Lines 5 to 19 of OfflineBanner become lines 4 to 18 of the hook.
  3. Return what the component needs to draw. Here that's one value, isOnline. A hook can return anything: a value, an array like useState does, or an object.
  4. Call it at the top level of each component, in place of the lines you moved: const isOnline = useOnlineStatus();.
  5. 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:

useOnlineStatus.js: the state and the Effect, movedJavaScript
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:

Two components, one hookPop out in the code editor (opens in a new tab)JSX
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 />    </>  );}
ResultLoading…

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:

useLocalStorage.js: state that is saved on the deviceJavaScript
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.

Two components that call useLocalStorage with the same keyPop out in the code editor (opens in a new tab)JSX
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 />    </>  );}
ResultLoading…

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.

Two windows, ShopHeader and ThemePicker. Each holds its own box named theme: the header's holds light, the picker's holds dark. Under them, a crate named localStorage holds dark. An arrow goes from the picker's box down to the crate. Nothing connects the header's box to the picker's box.ShopHeaderlightthemeThemePickerdarkthemenot sharedsetItemdarklocalStorage
After one click on Switch theme: each call to 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.

Playground: two components, one hook
App.jsx, in the version you pickedJSX
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 pagelocalStorage: empty

Loading the page...

Try it yourself
Both parts say light.

Two components, two calls to useLocalStorage("theme", "light"). Press Switch theme and watch the header.

This runs useLocalStorage exactly as in useLocalStorage.js, with React, on this page. It saves under its own key, cf-custom-hooks-theme, so it doesn't change the site's theme. Choosing a version or Reset clears that key.

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.

Each component calls the hook, or App calls it once
QuestionEach component calls the hookApp 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:

hooks.js: a debounced value and search resultsJavaScript
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.

A search box: one hook's result is the next hook's argumentPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

A chain. The card query, holding "tea", has an arrow to the function card useDebouncedValue, labelled waits 400 ms. Its arrow leads to the card debouncedQuery. An arrow goes down to the function card useSearchResults, labelled asks the server, and its arrow leads left to the card results, 3 products.query: "tea"typed nowuseDebouncedValuewaits 400 msdebouncedQueryuseSearchResultsasks the serverresults3 products
Each render, 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.

A hook's code is part of the render

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:

useInterval.js, with the callback as a dependencyJavaScript
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.

A note box that saves every 2 seconds (it doesn't, while you type)Pop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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.

Two halves. Left: inside useInterval, an Effect holds setInterval, with onTick and delay in its array, so every new onTick restarts the timer. Right: inside useInterval, tick wraps onTick with useEffectEvent, and the Effect holding setInterval lists only delay, so the timer keeps running and calls the newest onTick.onTick in the arrayuseIntervalsetInterval[onTick, delay]new onTick: restartWrapped in an Effect EventuseIntervaltick = useEffectEventsetInterval[delay]new onTick: keeps running
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:

useInterval.js, with the callback wrapped in an Effect EventJavaScript
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.

The same note box with the new useIntervalPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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:

A plain function and a hookJavaScript
// 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.

A useMount hook that misses a visit (don't do this)Pop out in the code editor (opens in a new tab)JSX
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} />    </>  );}
ResultLoading…

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:

A hook named for what it doesPop out in the code editor (opens in a new tab)JSX
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} />    </>  );}
ResultLoading…

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.

When to write a custom hook
What you haveWrite a custom hook?Why
The same state and Effect in two components, like the online statusYes.One name, one place to fix bugs, and components that read like what they do.
A function that only calculates, like sorting by priceNo. 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 useMountNo.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 jobOften, 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.

A search page made of four hooksPop out in the code editor (opens in a new tab)JSX
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 />    </>  );}
ResultLoading…

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.

A custom hook, or a plain function?
  • 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]); }
Try it yourself
0 of 8 correct

Sort each function: does it call hooks (so its name must start with use), or is it a plain function?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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. sortByPrice and formatPrice are plain functions.
  • "useMount makes Effects easier to read." No. It hides what the Effect reads from the linter, so a new productId was 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 useEffectEvent inside 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.

Exercise 1 · Warm-upTwo cards, one hook

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" />    </>  );}
ResultLoading…

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upExtract useToggle

    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 />    </>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeReplace useMount

      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} />    </>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeDebounce the search

        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>    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeOne theme for the whole page

          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 />    </>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeKeep the timer running

            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>    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              The custom hooks quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Two components each call useOnlineStatus(). What do they share?

                Choose an answer to see the explanation.

              2. Question 2 of 7You click Add tea twice. What does the cart badge show?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              3. Question 3 of 7This function calls no hooks. Which name should replace ___?

                Read the code, then predictJavaScript
                function ___(products) {
                  return [...products].sort((a, b) => a.price - b.price);
                }

                Choose an answer to see the explanation.

              4. Question 4 of 7When does the code inside a custom hook run?

                Choose an answer to see the explanation.

              5. Question 5 of 7ProductPage shows the tea page, and you then switch it to the cake page. What does logVisit log for cake?

                Read the code, then predictJSX
                function useMount(callback) {
                  useEffect(() => {
                    callback();
                  }, []);
                }
                
                function ProductPage({ productId }) {
                  useMount(() => logVisit(productId));
                  return <h3>Product: {productId}</h3>;
                }

                Choose an answer to see the explanation.

              6. Question 6 of 7App calls useInterval(() => { … }, 2000) with the useInterval shown here. You type one letter every second. When does the note save?

                Read the code, then predictJSX
                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]);
                }
                
                // In App.jsx:
                  useInterval(() => {
                    if (note !== saved) {
                      setSaved(note);
                      console.log(`Saved "${note}"`);
                    }
                  }, 2000);

                Choose an answer to see the explanation.

              7. Question 7 of 7Why does useInterval wrap onTick in useEffectEvent instead 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 use and 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 useEffectEvent inside the hook.
              • Give use only to functions that call hooks, and name hooks for what they keep in sync, never useMount.

              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.

              CompleteFrontend Clear concepts. Working examples.