Skip to content
Laviyx Learn

Try

React course menu

Custom Hooks in React: Reusing Stateful Logic

Write your own React hooks: extracting repeated state and effect logic into functions that start with use, examples like useToggle, useLocalStorage, useDebounce and useOnlineStatus, how each call gets its own state, and naming rules.

Intermediate11 min readUpdated
On this page

When two components need the same stateful logic (state plus effects plus handlers), you can extract it into a custom hook: a function whose name starts with use and that calls other hooks. Components then share the logic, not the state.

Your first custom hook#

JSX
import { useState } from "react";

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

export default function App() {
  const [menuOpen, toggleMenu] = useToggle();
  const [showPrices, togglePrices] = useToggle(true);

  return (
    <div>
      <button onClick={toggleMenu}>{menuOpen ? "Close" : "Open"} menu</button>{" "}
      <button onClick={togglePrices}>{showPrices ? "Hide" : "Show"} prices</button>
      {menuOpen && <p>Menu: Home · Shop · Contact</p>}
      <p>Notebook{showPrices && " — ₹60"}</p>
    </div>
  );
}
Result

Each call to useToggle has its own state, exactly like calling useState twice. A custom hook shares how state works, not the state itself. (To share the same state between components, lift it up or use context.)

Rules#

  • The name must start with use followed by a capital letter (useToggle, useOnlineStatus). This tells React and the linter that the function follows the rules of hooks.
  • Call custom hooks only at the top level of components or other hooks.
  • A function that does not call any hooks should be a normal function, not named use....

useLocalStorage: state that survives a reload#

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

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

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // storage can be unavailable (private mode, sandboxed frames): keep working in memory
    }
  }, [key, value]);

  return [value, setValue];
}

export default function App() {
  const [name, setName] = useLocalStorage("profile-name", "");
  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" />
      <p>Hello{name && `, ${name}`}!</p>
    </div>
  );
}
Result

In your own app, type a name and reload the page: it is still there. The previews on this site run in a sandboxed frame where browsers block localStorage, which is why the hook catches errors and keeps working in memory; it is good practice anyway, since storage can be unavailable in some browsers and modes.

useState(() => ...) with a function is a lazy initial value: the function runs only on the first render, so localStorage is not read on every render.

useDebounce: wait until typing stops#

Searching on every keystroke wastes requests. A debounced value only updates after the user pauses:

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);
  }, [value, delay]);
  return debounced;
}

const CITIES = ["Ahmedabad", "Bengaluru", "Bhopal", "Chennai", "Delhi", "Hyderabad", "Kochi", "Kolkata", "Mumbai", "Pune"];

export default function App() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 500);
  const results = CITIES.filter((c) => c.toLowerCase().includes(debouncedQuery.toLowerCase()));

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search cities" />
      <p>
        Typed: "{query}" · Searching for: "{debouncedQuery}"
      </p>
      <p>{results.join(", ") || "No cities"}</p>
    </div>
  );
}
Result

Every keystroke resets the timer (the effect's cleanup clears it), so the search runs only after 500 ms without typing.

useOnlineStatus: wrapping a browser API#

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

function useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);
  useEffect(() => {
    const goOnline = () => setOnline(true);
    const goOffline = () => setOnline(false);
    window.addEventListener("online", goOnline);
    window.addEventListener("offline", goOffline);
    return () => {
      window.removeEventListener("online", goOnline);
      window.removeEventListener("offline", goOffline);
    };
  }, []);
  return online;
}

export default function App() {
  const online = useOnlineStatus();
  return <p>{online ? "✅ Online" : "❌ Offline: changes will sync later"}</p>;
}
Result

The component reads like a sentence ("is the user online?") while the event listener details live in the hook. React also has a built-in hook, useSyncExternalStore, designed for subscribing to external data like this.

When to extract a hook#

  • The same useState + useEffect combination appears in several components.
  • A component is long and a chunk of logic has a clear name (useCart, useWindowSize).
  • You want to hide details (event listeners, timers, storage) behind a simple interface.

Do not extract a hook for every two lines; a good hook has a clear purpose and a clear name.

Common mistakes#

Practice#

Practice exercise

Write a useCounter(initial, step) hook that returns { count, increment, decrement, reset }, and use it twice: once for "Adults" (starting at 1) and once for "Children" (starting at 0), where children cannot go below 0.

Show hint

Inside the hook: const [count, setCount] = useState(initial), decrement uses setCount((c) => Math.max(0, c - step)), reset sets initial.

Show solution
JSX
import { useState } from "react";

function useCounter(initial = 0, step = 1) {
  const [count, setCount] = useState(initial);
  return {
    count,
    increment: () => setCount((c) => c + step),
    decrement: () => setCount((c) => Math.max(0, c - step)),
    reset: () => setCount(initial),
  };
}

function Row({ label, counter }) {
  return (
    <p>
      {label}: <button onClick={counter.decrement}>−</button> {counter.count}{" "}
      <button onClick={counter.increment}>+</button>
    </p>
  );
}

export default function App() {
  const adults = useCounter(1);
  const children = useCounter(0);
  return (
    <div>
      <Row label="Adults" counter={adults} />
      <Row label="Children" counter={children} />
      <p>Total travellers: {adults.count + children.count}</p>
    </div>
  );
}
Result

Key takeaway#

Next: Composition and children.

Finished this lesson?