React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

custom-hooksguidetutorialpatternsreacttypescriptbeginnerintermediate

Custom Hooks: Step-by-Step Guide

How to build custom hooks from scratch — starting simple, building to intermediate complexity.


Step-by-Step: How to Make a Custom Hook

  1. Identify repeated logic — If two or more components share the same stateful logic, extract it.
  2. Create a function that starts with use — React relies on this naming convention to apply the Rules of Hooks.
  3. Move the hooks insideuseState, useEffect, useRef, useCallback, etc. go into your function.
  4. Define the return value — Return whatever the consuming component needs: a value, a setter, an object, a tuple.
  5. Add TypeScript types — Type the parameters and return value. Use generics when the hook is value-agnostic.
  6. Memoize stable references — Wrap callbacks in useCallback and return objects in useMemo to prevent unnecessary re-renders in consumers.
  7. Handle cleanup — If your hook sets up subscriptions, timers, or listeners, clean them up in useEffect return functions.

Simple Hooks

1. useToggle — Boolean state with toggle

The simplest useful hook. Wraps useState(boolean) and returns a stable toggle function.

import { useState, useCallback } from "react";
 
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle] as const;
}

Usage:

function DarkModeButton() {
  const [isDark, toggleDark] = useToggle(false);
  return <button onClick={toggleDark}>{isDark ? "Light" : "Dark"}</button>;
}

Why it works: useCallback with an empty dependency array creates a stable function reference. The updater form (v) => !v avoids stale closures.


2. useDocumentTitle — Set the page title reactively

import { useEffect } from "react";
 
function useDocumentTitle(title: string) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

Usage:

function ProfilePage({ user }: { user: { name: string } }) {
  useDocumentTitle(`${user.name} — Profile`);
  return <h1>{user.name}</h1>;
}

Why it works: The effect runs only when title changes. No cleanup needed because setting document.title is idempotent.


3. useCounter — Numeric state with increment/decrement/reset

import { useState, useCallback, useMemo } from "react";
 
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
 
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initial), [initial]);
 
  return useMemo(
    () => ({ count, increment, decrement, reset }),
    [count, increment, decrement, reset]
  );
}

Usage:

function CartQuantity() {
  const { count, increment, decrement } = useCounter(1);
  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

Why it works: Returning a memoized object means consumers that destructure won't cause child re-renders from a new object reference every render.


4. useIsMounted — Check if the component is still mounted

Useful for guarding async callbacks that resolve after unmount.

import { useRef, useEffect, useCallback } from "react";
 
function useIsMounted() {
  const mounted = useRef(false);
 
  useEffect(() => {
    mounted.current = true;
    return () => { mounted.current = false; };
  }, []);
 
  return useCallback(() => mounted.current, []);
}

Usage:

function AsyncButton({ onClick }: { onClick: () => Promise<void> }) {
  const isMounted = useIsMounted();
  const [loading, setLoading] = useState(false);
 
  const handleClick = async () => {
    setLoading(true);
    await onClick();
    if (isMounted()) setLoading(false); // safe guard
  };
 
  return <button onClick={handleClick} disabled={loading}>Go</button>;
}

Intermediate Hooks

5. useLocalStorage — Persist state to localStorage with SSR safety

import { useState, useEffect, useCallback } from "react";
 
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    if (typeof window === "undefined") return initialValue;
    try {
      const stored = localStorage.getItem(key);
      return stored ? (JSON.parse(stored) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });
 
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // storage full or blocked
    }
  }, [key, value]);
 
  const remove = useCallback(() => {
    setValue(initialValue);
    localStorage.removeItem(key);
  }, [key, initialValue]);
 
  return [value, setValue, remove] as const;
}

Usage:

function ThemeSelector() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

Key details:

  • Lazy initializer in useState avoids reading localStorage on every render.
  • typeof window === "undefined" guard makes it SSR-safe.
  • Generic <T> lets it store strings, numbers, objects, arrays.

6. useDebounce — Delay value updates until input settles

import { useState, useEffect } from "react";
 
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
 
  return debounced;
}

Usage:

function SearchInput() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
 
  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);
 
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

Key details:

  • Each keystroke clears the previous timer via cleanup.
  • The debounced value only updates after delay ms of inactivity.

7. useClickOutside — Detect clicks outside a ref element

import { useEffect, useRef } from "react";
 
function useClickOutside<T extends HTMLElement>(
  handler: () => void
) {
  const ref = useRef<T>(null);
 
  useEffect(() => {
    const listener = (e: MouseEvent | TouchEvent) => {
      if (!ref.current || ref.current.contains(e.target as Node)) return;
      handler();
    };
 
    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);
    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [handler]);
 
  return ref;
}

Usage:

function Dropdown() {
  const [open, setOpen] = useState(false);
  const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
 
  return (
    <div ref={ref}>
      <button onClick={() => setOpen(true)}>Menu</button>
      {open && <ul><li>Option A</li><li>Option B</li></ul>}
    </div>
  );
}

Key details:

  • Uses mousedown (not click) so the dropdown closes before the click completes.
  • Both mouse and touch events are handled for mobile.
  • The handler should be wrapped in useCallback in the consumer to avoid re-attaching listeners every render.

8. useMediaQuery — Reactive CSS media query matching

import { useState, useEffect } from "react";
 
function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(query).matches;
  });
 
  useEffect(() => {
    const mql = window.matchMedia(query);
    const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
 
    mql.addEventListener("change", onChange);
    setMatches(mql.matches); // sync in case it changed before effect ran
 
    return () => mql.removeEventListener("change", onChange);
  }, [query]);
 
  return matches;
}

Usage:

function ResponsiveLayout() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

9. useFetch — Data fetching with loading/error states and abort

import { useState, useEffect, useRef } from "react";
 
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}
 
function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const abortRef = useRef<AbortController | null>(null);
 
  useEffect(() => {
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;
 
    setLoading(true);
    setError(null);
 
    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then(setData)
      .catch((err) => {
        if (err.name !== "AbortError") setError(err);
      })
      .finally(() => setLoading(false));
 
    return () => controller.abort();
  }, [url]);
 
  return { data, loading, error };
}

Usage:

function UserProfile({ id }: { id: string }) {
  const { data, loading, error } = useFetch<User>(`/api/users/${id}`);
 
  if (loading) return <Spinner />;
  if (error) return <p>Error: {error.message}</p>;
  return <h1>{data?.name}</h1>;
}

Key details:

  • Previous requests are aborted when url changes — prevents race conditions.
  • AbortError is silently caught so it doesn't show as an error state.
  • For production, consider libraries like TanStack Query instead; this is for learning the pattern.

10. useEventListener — Type-safe, declarative event binding

import { useEffect, useRef } from "react";
 
function useEventListener<K extends keyof WindowEventMap>(
  event: K,
  handler: (e: WindowEventMap[K]) => void,
  element?: HTMLElement | null
) {
  const handlerRef = useRef(handler);
 
  useEffect(() => {
    handlerRef.current = handler;
  }, [handler]);
 
  useEffect(() => {
    const target = element ?? window;
    const listener = (e: Event) => handlerRef.current(e as WindowEventMap[K]);
 
    target.addEventListener(event, listener);
    return () => target.removeEventListener(event, listener);
  }, [event, element]);
}

Usage:

function EscapeHandler({ onEscape }: { onEscape: () => void }) {
  useEventListener("keydown", (e) => {
    if (e.key === "Escape") onEscape();
  });
  return null;
}

Key details:

  • handlerRef pattern: the listener is stable (never re-attached), but always calls the latest handler. This avoids stale closures without requiring handler in the dependency array.
  • Generic K extends keyof WindowEventMap gives full autocomplete on event names and typed event objects.

Gotchas

  1. The use prefix is required. React's linter and rules of hooks depend on it. toggleState() won't get lint warnings if you break hook rules inside it.

  2. Don't call hooks conditionally inside your custom hook. The same rules apply — hooks must be called in the same order every render.

  3. Stale closures in effects. If your hook captures a callback prop in useEffect, use a ref (useRef + useEffect to keep it fresh) instead of adding the callback to the dependency array.

  4. Returning new objects/arrays every render. If your hook returns { value, toggle } without useMemo, every consumer re-renders when the hook's parent re-renders — even if nothing changed.

  5. Missing cleanup. Forgetting to clear timers, remove listeners, or abort fetches causes memory leaks and state updates on unmounted components.

  6. SSR hydration mismatches. Hooks that read window, document, localStorage, or matchMedia need guards (typeof window === "undefined") and sometimes a two-pass render strategy.

  7. Overusing custom hooks. Not everything needs to be a hook. If the logic is pure (no hooks inside), make it a plain function. If it's only used in one component and unlikely to be reused, keep it inline.

  8. Generic type inference. When using generics like useLocalStorage<T>, ensure the initial value matches the generic or TypeScript will infer unknown. Provide explicit type arguments when the initial value is ambiguous (e.g., null).

  9. Effect dependencies with objects/arrays. Passing an object or array as a dependency to useEffect inside a custom hook causes it to re-run every render (new reference). Destructure to primitives or use a deep-compare utility.

  10. Testing custom hooks. Use renderHook from @testing-library/react — you cannot call hooks outside of a component. Wrap in act() when triggering state updates.