Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 26 of 38

Custom hooks

Extract reusable logic into your own React hooks: useToggle, useCounter, useDebounce, useLocalStorage and a data hook, plus the rules for naming and using them.

20 minEffects and dataUpdated 24 October 2026

By the end of this lessonTurn repeated state and effect logic into a custom hook, use it in several components, and explain what is and is not shared between them.

Components share markup by composing other components. What about sharing logic: the same combination of state and effects, repeated in several components? You could copy and paste it, but then a fix needs making in many places. React's answer is the custom hook: a function that you write, which uses other hooks inside, and which any component can call.

The idea: extract and name

Here is a small piece of logic: a boolean you can switch on and off. Without a hook, every component that needs it writes the same lines:

jsx
import { useState } from "react";

function App() {
  const [menuOpen, setMenuOpen] = useState(false);
  const [helpOpen, setHelpOpen] = useState(false);

  return (
    <div>
      <button className="menu" onClick={() => setMenuOpen(!menuOpen)}>Menu</button>
      <button className="help" onClick={() => setHelpOpen(!helpOpen)}>Help</button>
      <p>Menu is {menuOpen ? "open" : "closed"}, help is {helpOpen ? "open" : "closed"}.</p>
    </div>
  );
}

export default App;
result
MenuHelp
Menu is open, help is open.

Extract the repeated part into a function whose name starts with use:

jsx
import { useState } from "react";

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = () => setOn(value => !value);
  return [on, toggle];
}

function App() {
  const [menuOpen, toggleMenu] = useToggle();
  const [helpOpen, toggleHelp] = useToggle(true);

  return (
    <div>
      <button className="menu" onClick={toggleMenu}>Menu</button>
      <button className="help" onClick={toggleHelp}>Help</button>
      <p>Menu is {menuOpen ? "open" : "closed"}, help is {helpOpen ? "open" : "closed"}.</p>
    </div>
  );
}

export default App;
result
MenuHelp
Menu is open, help is closed.

useToggle is an ordinary function that calls useState and returns what the component needs. After the test clicks, the menu is open and the help (which started open) is closed. The component now reads as a statement of intent: "a toggle for the menu, a toggle for help". Notice the pieces:

  • The name starts with use. This is how React and the linter know that the function calls hooks, so the rules of hooks must be applied to it. It is a convention enforced by tooling.
  • It can accept arguments and return anything: an array, an object, a single value, a function.
  • It is called at the top level of a component, like any hook.

Logic is shared, state is not

This is the point that surprises people. Calling a custom hook twice, in one component or two, gives two independent copies of its state. A hook shares the code, not the data.

jsx
import { useState } from "react";

function useCounter(step = 1) {
  const [count, setCount] = useState(0);
  const increase = () => setCount(c => c + step);
  const reset = () => setCount(0);
  return { count, increase, reset };
}

function Counter({ label, step }) {
  const { count, increase } = useCounter(step);
  return <button onClick={increase}>{label}: {count}</button>;
}

function App() {
  return (
    <ul>
      <li><Counter label="Ones" step={1} /></li>
      <li><Counter label="Tens" step={10} /></li>
    </ul>
  );
}

export default App;
result
Ones: 2
Tens: 10

The first counter was clicked twice (1, 2), the second once (10), and neither affected the other. If you want components to share one value, lift the state up or use context; a hook does not do that by itself.

A hook with an effect: useDebounce

Debouncing waits until a value stops changing before acting on it. It is how a search box avoids sending a request for every keystroke. The hook wraps a state, an effect and a cleanup:

jsx
import { useEffect, useState } from "react";

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);          // typing again cancels the pending update
  }, [value, delay]);
  return debounced;
}

function App() {
  const [text, setText] = useState("");
  const search = useDebounce(text, 300);

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} placeholder="Search" />
      <p>Typed: {text}</p>
      <p>Would search for: {search}</p>
    </div>
  );
}

export default App;
result
Typed: react
Would search for: react

Each keystroke starts a 300 ms timer and cancels the previous one, so "Would search for" only changes after typing pauses. The component that uses it needs none of this machinery. In a real search you would pass search to a fetch effect.

A hook for the browser: useLocalStorage

A common hook keeps a state value in localStorage so it survives page reloads. Because the lesson editor runs in a sandbox that blocks storage, the version below falls back to plain memory; in a real app, localStorage works normally:

jsx
import { useEffect, useState } from "react";

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = window.localStorage.getItem(key);
      return saved !== null ? JSON.parse(saved) : initialValue;
    } catch {
      return initialValue;                         // storage blocked or invalid JSON
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // ignore: private mode, quota exceeded, or a sandboxed frame
    }
  }, [key, value]);

  return [value, setValue];
}

function App() {
  const [visits, setVisits] = useLocalStorage("visits", 0);
  return <button onClick={() => setVisits(v => v + 1)}>Visits: {visits}</button>;
}

export default App;
result
Visits: 2

Three habits are on display: reading the initial value with a lazy initialiser (a function passed to useState, run only once), writing back in an effect whenever the value changes, and protecting against errors with try/catch, since storage can be unavailable. Components that use the hook look the same as if they used useState. (Remember that the hook above is for the browser; code that runs on a server has no window.)

A hook for data: useFetch

The data-fetching lesson built a loading, error and success pattern with an ignore flag. That belongs in a hook, so it is written once. This version takes a function that returns a promise (a fake API here, so the example is predictable):

jsx
import { useEffect, useState } from "react";

function useAsync(load, dependencies) {
  const [state, setState] = useState({ status: "loading", data: null, error: null });

  useEffect(() => {
    let ignore = false;
    setState({ status: "loading", data: null, error: null });
    load()
      .then(data => { if (!ignore) setState({ status: "success", data, error: null }); })
      .catch(error => { if (!ignore) setState({ status: "error", data: null, error }); });
    return () => { ignore = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, dependencies);

  return state;
}

const fakeApi = id => new Promise((resolve, reject) =>
  setTimeout(() => (id === 3 ? reject(new Error("Not found")) : resolve({ id, name: "Item " + id })), 100));

function App() {
  const [id, setId] = useState(1);
  const { status, data, error } = useAsync(() => fakeApi(id), [id]);

  return (
    <div>
      <button className="b" onClick={() => setId(id + 1)}>Next (id {id})</button>
      {status === "loading" && <p>Loading...</p>}
      {status === "success" && <p>{data.name}</p>}
      {status === "error" && <p role="alert">Error: {error.message}</p>}
    </div>
  );
}

export default App;
result
Next (id 2)
Item 2

The test pressed the button once (id 2) and let the request finish. This hook takes a dependency list from the caller, which is why the lint comment appears: the linter cannot see through the dependencies parameter. That limitation is one reason real projects prefer a library such as TanStack Query over a hand-made useFetch. Still, writing this once shows what such libraries do.

Rules and conventions

  1. Start the name with use, followed by a capital letter: useToggle, not toggle or usetoggle.
  2. Call hooks at the top level, never in loops, conditions or handlers, also inside your custom hooks.
  3. Only call hooks from components or other hooks.
  4. Return what callers need, no more: an array for two or three related values (like useState), an object when there are many or when names help.
  5. A custom hook should have one clear purpose, described by its name. Avoid "lifecycle" hooks such as useMount; they hide what the effect really synchronises.
  6. Put hooks in their own files (src/hooks/useToggle.js) once you reuse them.
  7. Test them through a component, or with a testing helper such as renderHook from React Testing Library.

When to write one

Extract a custom hook when:

  • the same state-and-effect combination appears in more than one component,
  • a component is hard to read because of a block of hook code that does one nameable job,
  • you want to hide a browser or library API behind a simple interface (useOnlineStatus, useWindowSize).

Do not extract a hook just to wrap a single useState without adding meaning, and do not worry about hooks for logic used once; plain code in the component is fine. Before writing a hook, check whether a library already has it: popular collections (such as usehooks-ts) offer well-tested versions of most of the examples above.

Common mistakes

  • Naming the function without use, so the linter cannot check it.
  • Expecting two components that call the same hook to share state.
  • Calling a hook inside a condition, loop or an event handler.
  • Returning a new object or function on every render and passing it as a dependency elsewhere, which re-runs effects (see the memoization lesson).
  • Writing a "do everything" hook that mixes unrelated concerns.
  • Forgetting that code like window or localStorage fails during server rendering and in sandboxed frames.

Exercise

  1. Write useWindowWidth() that returns the current width and updates on resize, with a proper cleanup. Use it in two components.
  2. Add a useCounter option max so the counter stops at that value.
  3. Write usePrevious(value) that returns the value from the previous render (hint: a ref and an effect).
  4. Use useDebounce with the fetching example from the previous lesson to search todos without sending a request for each key.

Key points

  • A custom hook is a function whose name starts with use and which calls other hooks; it lets components share logic, not state.
  • Each component that calls a custom hook gets its own independent state and effects.
  • Extract a hook when the same combination of state and effects appears in several components, or when it gives a clear name to a block of logic.
  • Custom hooks follow the rules of hooks: call them at the top level of a component or another hook.

Check yourself

1. What must the name of a custom hook start with?
2. Two components both call useCounter(). Do they share the same count?
3. Which of these is a good candidate for a custom hook?
4. What does useDebounce(value, 300) usually return?