Custom Hooks

React · Lesson 19 of 29 6 min read Updated

After a while you notice the same logic in several components: save a value to localStorage, wait until the user stops typing, track whether the device is online. Copying that code into every component is error-prone. A custom hook lets you pull the logic out into one function and reuse it anywhere.

In this lesson you will write your first custom hooks, understand that hooks share logic and not state, follow the naming rules, and use useSyncExternalStore to subscribe to browser data.

Extracting a hook

A custom hook is a JavaScript function whose name starts with use and which calls other hooks. Here is a component that remembers a name in localStorage, before any extraction:

React JSX
function Greeting() {
  const [name, setName] = useState(() => localStorage.getItem("name") ?? "");
  useEffect(() => {
    localStorage.setItem("name", name);
  }, [name]);
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

If another component needs to remember a city the same way, you would copy the useState and useEffect lines and change the key. Instead, move them into a function that takes the key and the default value as arguments:

React JSXTry it yourself
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";

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

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // storage may be full or blocked; the app still works without it
    }
  }, [key, value]);

  return [value, setValue];
}

function Profile() {
  const [name, setName] = useLocalStorage("profile-name", "Kabir");
  const [city, setCity] = useLocalStorage("profile-city", "Lucknow");

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={city} onChange={(e) => setCity(e.target.value)} />
      <p>{name} from {city}. Change the text and run the example again: it is remembered.</p>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Profile />);

useLocalStorage returns [value, setValue], the same shape as useState, so it feels natural to use. The component no longer knows how the value is saved; it only says which key to use.

Hooks share logic, not state

Profile calls useLocalStorage twice, and the name and the city do not interfere. Each call to a custom hook creates its own, completely separate state, exactly as if you had written the useState and useEffect lines directly in the component. If you need two components to share the same state, lift it up or use context; a custom hook alone will not do that.

Custom hooks follow the same rules as built-in hooks:

  • The name must start with use followed by a capital letter, such as useCart. This tells React's linter to check the rules of hooks inside it, and tells readers that it may hold state.
  • Call hooks only at the top level of the custom hook, never inside conditions or loops.
  • Call custom hooks only from components or other custom hooks.
  • A custom hook re-runs on every render of its component, so it always receives the latest props and state. Keep it pure apart from what its effects do.

A function that does not call any hooks should not start with use; make it a normal helper function instead, such as formatRupees(amount).

Passing values in: useDebouncedValue

Hooks can take reactive values as arguments and return a derived result. A debounce waits until a value stops changing for a while. It is perfect for search boxes, so you do not search after every single key press:

React JSXTry it yourself
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";

function useDebouncedValue(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id); // a new key press cancels the old timer
  }, [value, delay]);
  return debounced;
}

const cities = ["Jaipur", "Jaisalmer", "Jalandhar", "Jammu", "Pune", "Puducherry", "Kochi", "Kolkata"];

function CitySearch() {
  const [text, setText] = useState("");
  const query = useDebouncedValue(text, 400);

  useEffect(() => {
    if (query) console.log(`Searching for "${query}"`);
  }, [query]);

  const results = cities.filter((c) => c.toLowerCase().startsWith(query.toLowerCase()));

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a city" />
      <p>Showing results for: "{query}"</p>
      <ul>{results.map((c) => <li key={c}>{c}</li>)}</ul>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<CitySearch />);

Typing "jai" quickly prints only one line, after you pause:

Output
Searching for "jai"

The input itself updates instantly from text; only the expensive work waits for query. Each key press changes value, which runs the effect's cleanup and cancels the previous timer.

Subscribing to the browser: useSyncExternalStore

Some data lives outside React and can change at any moment: online status, screen size, a third-party store. React provides useSyncExternalStore(subscribe, getSnapshot) to read such data safely. Wrapping it in a custom hook gives you a tidy API:

React JSXTry it yourself
import { useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";

function subscribe(callback) {
  window.addEventListener("online", callback);
  window.addEventListener("offline", callback);
  return () => {
    window.removeEventListener("online", callback);
    window.removeEventListener("offline", callback);
  };
}

function useOnlineStatus() {
  return useSyncExternalStore(subscribe, () => navigator.onLine);
}

function SaveButton() {
  const isOnline = useOnlineStatus();
  return <button disabled={!isOnline}>{isOnline ? "Save to cloud" : "Reconnecting..."}</button>;
}

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <p>{isOnline ? "Online" : "You are offline. Changes are kept on this device."}</p>;
}

createRoot(document.getElementById("root")).render(
  <>
    <StatusBar />
    <SaveButton />
  </>
);

subscribe tells React how to listen for changes and returns an unsubscribe function; getSnapshot reads the current value. Turn off your network (or use the browser's developer tools to go offline) and both components update. Define subscribe outside the component so it is the same function on every render.

Good and bad custom hooks

Good custom hooks describe a purpose: useLocalStorage, useOnlineStatus, useDebouncedValue, useCart, useChatRoom(roomId). Avoid hooks that only rename lifecycle moments, such as useMount(fn) or useUpdateEffect(fn); they hide which values an effect depends on and make bugs harder to find.

Common mistakes

  • Expecting shared state. Two components calling useCart() each get their own state unless the hook reads a shared context.
  • Naming without use. getUserData() that calls useState breaks the rules-of-hooks checks; name it useUserData.
  • Calling hooks conditionally inside the custom hook. The same top-level rule applies.
  • Returning unstable objects. A new object or function on every render can retrigger effects in components that depend on it; return simple values where possible.

Key points

  • A custom hook is a function named useSomething that calls other hooks.
  • It lets components reuse stateful logic such as storage, timers and subscriptions.
  • Each call has its own independent state; hooks share logic, not data.
  • Pass reactive values in as arguments and return what components need.
  • useSyncExternalStore subscribes safely to data outside React, such as navigator.onLine.
  • Name hooks after their purpose, not after lifecycle moments.

Exercise

Write a custom hook useToggle(initial = false) that returns [on, toggle], where toggle() flips the value. Then write useCountdown(seconds) that counts down once per second to zero and returns the remaining seconds, cleaning up its interval. Use both in an OtpScreen component: show "Resend OTP in N s", enable a "Resend" button at zero, and add a "Show OTP" toggle that reveals the code 482913.

Show solution
React JSX
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";

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

function useCountdown(seconds) {
  const [left, setLeft] = useState(seconds);
  useEffect(() => {
    if (left === 0) return;
    const id = setInterval(() => setLeft((s) => s - 1), 1000);
    return () => clearInterval(id);
  }, [left]);
  return left;
}

function OtpScreen() {
  const [show, toggleShow] = useToggle();
  const left = useCountdown(10);

  return (
    <div>
      <p>OTP: {show ? "482913" : "••••••"} <button onClick={toggleShow}>{show ? "Hide" : "Show"} OTP</button></p>
      <p>{left > 0 ? `Resend OTP in ${left} s` : "You can resend now"}</p>
      <button disabled={left > 0}>Resend</button>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<OtpScreen />);

Each hook hides its own state and effect. useCountdown stops creating intervals once it reaches zero, and every interval is cleared in the cleanup.

Quick Quiz

1. What makes a function a custom hook?

2. Two components call the same custom hook useCounter(). What happens to their state?

3. What is useSyncExternalStore for?

4. Which is the better custom hook name?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call