On this page

Custom Hooks: Reusable Stateful Logic

14 min read TextCh. 3 — Patterns and Navigation

What Are Custom Hooks?

A custom hook is a JavaScript function whose name starts with use and that calls other hooks. Custom hooks let you extract and reuse stateful logic between components without changing the component hierarchy.

Custom hooks follow all the same rules as built-in hooks — they can only be called at the top level of components or other hooks.

// This is a custom hook
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

// Used in any component
function ResponsiveLayout() {
  const width = useWindowWidth();
  return <div>{width < 768 ? <MobileNav /> : <DesktopNav />}</div>;
}

Why Extract Logic into Custom Hooks?

Before custom hooks, sharing stateful logic required patterns like render props or higher-order components — both added nesting and complexity. Custom hooks provide a clean, composable alternative:

  • Reusability: The same logic can be used in any component.
  • Separation of concerns: Components focus on rendering; hooks handle logic.
  • Testability: Hooks can be tested independently using renderHook from Testing Library.
  • Readability: Component bodies become declarative and easy to scan.

useFetch — Generic Data Fetching Hook

The generic useFetch hook handles loading, error, and data states for any URL:

interface Post {
  id: number;
  title: string;
  body: string;
}

function PostList() {
  const { data, loading, error, refetch } = useFetch<Post[]>(
    'https://jsonplaceholder.typicode.com/posts?_limit=5'
  );

  if (loading) return <p>Loading...</p>;
  if (error) return (
    <div role="alert">
      <p>Error: {error}</p>
      <button type="button" onClick={refetch}>Retry</button>
    </div>
  );

  return (
    <ul>
      {data?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

The hook encapsulates all the useState and useEffect complexity, leaving the component clean and declarative.

useDebounce — Delaying Expensive Operations

import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Usage — avoids API calls on every keystroke
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);

  const { data, loading } = useFetch<SearchResult[]>(
    debouncedQuery
      ? `/api/search?q=${encodeURIComponent(debouncedQuery)}`
      : ''
  );

  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      {loading && <span>Searching...</span>}
      {data?.map((result) => (
        <p key={result.id}>{result.title}</p>
      ))}
    </div>
  );
}

useToggle — Boolean State with Helper Functions

import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse } as const;
}

function Modal() {
  const { value: isOpen, toggle: toggleModal, setFalse: closeModal } = useToggle();

  return (
    <>
      <button type="button" onClick={toggleModal}>
        {isOpen ? 'Close' : 'Open'} modal
      </button>
      {isOpen && (
        <dialog open aria-modal="true">
          <h2>Modal Title</h2>
          <p>Modal content goes here.</p>
          <button type="button" onClick={closeModal}>Close</button>
        </dialog>
      )}
    </>
  );
}

useMediaQuery — Responsive Logic in Hooks

import { useState, useEffect } from 'react';

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  );

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

// Usage
function ThemeToggle() {
  const prefersD = useMediaQuery('(prefers-color-scheme: dark)');
  const isSmall = useMediaQuery('(max-width: 640px)');

  return (
    <div>
      <p>System theme: {prefersD ? 'Dark' : 'Light'}</p>
      <p>Viewport: {isSmall ? 'Mobile' : 'Desktop'}</p>
    </div>
  );
}

useCounter — Numeric State with Constraints

import { useState, useCallback } from 'react';

interface UseCounterOptions {
  min?: number;
  max?: number;
  step?: number;
}

function useCounter(initialValue = 0, options: UseCounterOptions = {}) {
  const { min = -Infinity, max = Infinity, step = 1 } = options;
  const [count, setCount] = useState(
    () => Math.min(Math.max(initialValue, min), max)
  );

  const increment = useCallback(() => {
    setCount((c) => Math.min(c + step, max));
  }, [step, max]);

  const decrement = useCallback(() => {
    setCount((c) => Math.max(c - step, min));
  }, [step, min]);

  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  const set = useCallback((value: number) => {
    setCount(Math.min(Math.max(value, min), max));
  }, [min, max]);

  return { count, increment, decrement, reset, set } as const;
}

// Usage
function QuantityPicker() {
  const { count, increment, decrement, reset } = useCounter(1, { min: 1, max: 99 });

  return (
    <div className="quantity-picker">
      <button type="button" onClick={decrement} disabled={count <= 1}>−</button>
      <output>{count}</output>
      <button type="button" onClick={increment} disabled={count >= 99}>+</button>
      <button type="button" onClick={reset}>Reset</button>
    </div>
  );
}

Composing Custom Hooks

Custom hooks can call other custom hooks, enabling composition of complex behaviors:

function useAuthenticatedFetch<T>(endpoint: string) {
  const { token } = useAuth(); // custom hook for auth state
  const url = token ? `/api${endpoint}` : '';

  const result = useFetch<T>(url); // custom hook for fetching

  return {
    ...result,
    isAuthenticated: !!token,
  };
}

function UserDashboard() {
  const { data, loading, isAuthenticated } = useAuthenticatedFetch<DashboardData>('/dashboard');

  if (!isAuthenticated) return <p>Please log in.</p>;
  if (loading) return <p>Loading dashboard...</p>;

  return <Dashboard data={data!} />;
}

Hook Testing Pattern

Custom hooks can be tested with @testing-library/react's renderHook:

import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('respects max boundary', () => {
    const { result } = renderHook(() => useCounter(9, { max: 10 }));
    act(() => result.current.increment());
    expect(result.current.count).toBe(10);
    act(() => result.current.increment()); // cannot go above max
    expect(result.current.count).toBe(10);
  });
});

In the next lesson, you will learn about the Context API — the built-in solution for sharing state across the component tree without prop drilling.

Hooks must start with 'use'
The `use` prefix is not just a convention — it is required. The React ESLint plugin uses it to enforce the Rules of Hooks. Functions starting with `use` are treated as hooks and validated to be called only at the top level of components and other hooks.
Custom hooks share logic, not state
When two components use the same custom hook, each gets its own independent state. Custom hooks are a mechanism for sharing stateful *logic*, not a singleton. If you call `useCounter()` in two components, each component gets its own counter.
Never call hooks inside loops or conditionals
Hooks must be called unconditionally and in the same order on every render. This is why you cannot call hooks inside `if`, `for`, or nested functions. Move conditional logic inside the hook, not around the hook call itself.
import { useState, useEffect, useCallback } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

interface UseFetchReturn<T> extends FetchState<T> {
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchReturn<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });
  const [refreshKey, setRefreshKey] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    setState((prev) => ({ ...prev, loading: true, error: null }));

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((err: unknown) => {
        if (err instanceof Error && err.name !== 'AbortError') {
          setState({ data: null, loading: false, error: err.message });
        }
      });

    return () => controller.abort();
  }, [url, refreshKey]);

  const refetch = useCallback(() => setRefreshKey((k) => k + 1), []);

  return { ...state, refetch };
}

export { useFetch };