React Custom Hooks: Build Reusable Logic (2026)

Custom hooks are the single best tool for code reuse in React. They let you extract stateful logic from components into standalone functions that any component can consume — without changing your component hierarchy. This guide covers ten production-ready hooks with full TypeScript types, cleanup patterns, and testing examples.

Table of Contents

Rules of Hooks Recap

Custom hooks must follow the same rules as built-in hooks or React will throw runtime errors:

  • Only call hooks at the top level — not inside loops, conditions, or nested functions.
  • Only call hooks from React functions — function components or other custom hooks, never plain JS functions.
  • Name custom hooks with the use prefix — this is how the React linter (eslint-plugin-react-hooks) identifies them and enforces the rules above.
Note: A function that calls no hooks does not need the use prefix. The prefix is a signal to linters and readers that the function participates in the hook rules contract.

useFetch with AbortController

The most common custom hook. Wraps fetch with loading/error state and — critically — cancels in-flight requests when the component unmounts or the URL changes. Without the AbortController cleanup you get state updates on unmounted components and memory leaks.

// hooks/useFetch.ts
import { useState, useEffect, useCallback } from 'react';

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

export function useFetch<T = unknown>(url: string, options?: RequestInit): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [trigger, setTrigger] = useState(0);

  const refetch = useCallback(() => setTrigger((n) => n + 1), []);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { ...options, signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        return res.json() as Promise<T>;
      })
      .then((json) => {
        setData(json);
        setLoading(false);
      })
      .catch((err: Error) => {
        if (err.name === 'AbortError') return; // ignore cancellation
        setError(err);
        setLoading(false);
      });

    // Cleanup: cancel the request when url changes or component unmounts
    return () => controller.abort();
  }, [url, trigger]); // eslint-disable-line react-hooks/exhaustive-deps

  return { data, loading, error, refetch };
}

// Usage
function UserProfile({ userId }: { userId: string }) {
  const { data: user, loading, error, refetch } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message} <button onClick={refetch}>Retry</button></p>;
  return <p>{user?.name}</p>;
}
Pro Tip: For production apps prefer TanStack Query over useFetch — it adds caching, deduplication, and background refetching. Use useFetch for simple one-off fetches or when you can't add dependencies.

useLocalStorage

Keeps a piece of state in sync with localStorage, handling JSON serialization, deserialization, and SSR safety (localStorage doesn't exist on the server).

// hooks/useLocalStorage.ts
import { useState, useEffect, useCallback } from 'react';

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  const readValue = useCallback((): T => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  }, [key, initialValue]);

  const [storedValue, setStoredValue] = useState<T>(readValue);

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      const newValue = value instanceof Function ? value(storedValue) : value;
      setStoredValue(newValue);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(newValue));
        // Notify other tabs
        window.dispatchEvent(new StorageEvent('storage', { key, newValue: JSON.stringify(newValue) }));
      }
    },
    [key, storedValue]
  );

  const removeValue = useCallback(() => {
    setStoredValue(initialValue);
    if (typeof window !== 'undefined') window.localStorage.removeItem(key);
  }, [key, initialValue]);

  // Sync with other tabs
  useEffect(() => {
    const handler = (e: StorageEvent) => {
      if (e.key === key) setStoredValue(e.newValue ? JSON.parse(e.newValue) : initialValue);
    };
    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  }, [key, initialValue]);

  return [storedValue, setValue, removeValue];
}

// Usage
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'dark');
  return (
    <button onClick={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}>
      Current: {theme}
    </button>
  );
}

useDebounce and useThrottle

useDebounce delays updating a value until the user has stopped changing it for a specified duration — perfect for search inputs that fire API calls. useThrottle limits updates to at most once per interval — useful for scroll handlers.

// hooks/useDebounce.ts
import { useState, useEffect } from 'react';

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

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

  return debouncedValue;
}

// hooks/useThrottle.ts
import { useState, useEffect, useRef } from 'react';

export function useThrottle<T>(value: T, interval = 200): T {
  const [throttledValue, setThrottledValue] = useState<T>(value);
  const lastUpdated = useRef<number | null>(null);

  useEffect(() => {
    const now = Date.now();
    if (lastUpdated.current === null || now - lastUpdated.current >= interval) {
      lastUpdated.current = now;
      setThrottledValue(value);
    } else {
      const timer = setTimeout(() => {
        lastUpdated.current = Date.now();
        setThrottledValue(value);
      }, interval - (now - (lastUpdated.current ?? now)));
      return () => clearTimeout(timer);
    }
  }, [value, interval]);

  return throttledValue;
}

// Usage — search input with debounce
function SearchBox() {
  const [input, setInput] = useState('');
  const debouncedQuery = useDebounce(input, 400);
  const { data: results } = useFetch<SearchResult[]>(
    debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : ''
  );

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Search..." />
      {results?.map((r) => <p key={r.id}>{r.title}</p>)}
    </div>
  );
}

useClickOutside

Fires a callback when the user clicks anywhere outside a referenced element. Used for closing dropdowns, modals, and tooltips.

// hooks/useClickOutside.ts
import { useEffect, useRef, RefObject } from 'react';

export function useClickOutside<T extends HTMLElement>(
  callback: () => void
): RefObject<T> {
  const ref = useRef<T>(null);

  useEffect(() => {
    const handler = (event: MouseEvent | TouchEvent) => {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        callback();
      }
    };

    document.addEventListener('mousedown', handler);
    document.addEventListener('touchstart', handler);

    return () => {
      document.removeEventListener('mousedown', handler);
      document.removeEventListener('touchstart', handler);
    };
  }, [callback]);

  return ref;
}

// Usage
function Dropdown() {
  const [open, setOpen] = useState(false);
  const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button onClick={() => setOpen((o) => !o)}>Menu</button>
      {open && (
        <ul style={{ position: 'absolute', top: '100%', left: 0 }}>
          <li>Option 1</li>
          <li>Option 2</li>
        </ul>
      )}
    </div>
  );
}

useWindowSize

Returns the current window dimensions and updates reactively on resize. Includes cleanup to remove the event listener on unmount.

// hooks/useWindowSize.ts
import { useState, useEffect } from 'react';

interface WindowSize {
  width: number;
  height: number;
}

export function useWindowSize(): WindowSize {
  const [size, setSize] = useState<WindowSize>({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  });

  useEffect(() => {
    const handler = () =>
      setSize({ width: window.innerWidth, height: window.innerHeight });

    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  return size;
}

// Usage
function ResponsiveNav() {
  const { width } = useWindowSize();
  return width < 768 ? <MobileNav /> : <DesktopNav />;
}

useIntersectionObserver

Tracks whether an element is visible in the viewport using the Intersection Observer API. Powers lazy image loading, infinite scroll triggers, and animation-on-enter effects.

// hooks/useIntersectionObserver.ts
import { useState, useEffect, useRef, RefObject } from 'react';

interface Options extends IntersectionObserverInit {
  freezeOnceVisible?: boolean;
}

export function useIntersectionObserver<T extends HTMLElement>(
  options: Options = {}
): [RefObject<T>, boolean] {
  const { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false } = options;
  const ref = useRef<T>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        const visible = entry.isIntersecting;
        setIsVisible(visible);
        if (freezeOnceVisible && visible) observer.disconnect();
      },
      { threshold, root, rootMargin }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, [threshold, root, rootMargin, freezeOnceVisible]);

  return [ref, isVisible];
}

// Usage — lazy load image
function LazyImage({ src, alt }: { src: string; alt: string }) {
  const [ref, isVisible] = useIntersectionObserver<HTMLDivElement>({
    threshold: 0.1,
    freezeOnceVisible: true,
  });

  return (
    <div ref={ref} style={{ minHeight: 200 }}>
      {isVisible ? (
        <img src={src} alt={alt} loading="lazy" />
      ) : (
        <div className="placeholder" />
      )}
    </div>
  );
}

// Usage — infinite scroll
function InfiniteList() {
  const [items, setItems] = useState<string[]>([]);
  const [sentinelRef, isVisible] = useIntersectionObserver<HTMLDivElement>({ threshold: 1 });

  useEffect(() => {
    if (isVisible) {
      // load next page
      setItems((prev) => [...prev, ...Array.from({ length: 10 }, (_, i) => `Item ${prev.length + i + 1}`)]);
    }
  }, [isVisible]);

  return (
    <div>
      {items.map((item) => <div key={item}>{item}</div>)}
      <div ref={sentinelRef} />
    </div>
  );
}

usePrevious

Stores the previous render's value using a ref. Useful for animating from old state to new state, or detecting which direction a list changed.

// hooks/usePrevious.ts
import { useRef, useEffect } from 'react';

export function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]); // runs after render — ref.current still holds the OLD value during render

  return ref.current;
}

// Usage — show change direction
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>
        {count} {prevCount !== undefined && `(was ${prevCount})`}
      </p>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
      <button onClick={() => setCount((c) => c - 1)}>-</button>
    </div>
  );
}

Testing Custom Hooks with renderHook

renderHook from @testing-library/react lets you test hooks in isolation without wrapping them in a real component. Pair it with act for state updates and waitFor for async operations.

// hooks/__tests__/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from '../useDebounce';

describe('useDebounce', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('returns the initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('hello', 500));
    expect(result.current).toBe('hello');
  });

  it('does not update before the delay has elapsed', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: 'hello' } }
    );

    rerender({ value: 'world' });
    act(() => { jest.advanceTimersByTime(300); });
    expect(result.current).toBe('hello');
  });

  it('updates after the delay has elapsed', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 500),
      { initialProps: { value: 'hello' } }
    );

    rerender({ value: 'world' });
    act(() => { jest.advanceTimersByTime(500); });
    expect(result.current).toBe('world');
  });
});

// hooks/__tests__/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from '../useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => localStorage.clear());

  it('returns the initial value when localStorage is empty', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'default'));
    expect(result.current[0]).toBe('default');
  });

  it('persists a new value to localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'default'));
    act(() => { result.current[1]('updated'); });
    expect(result.current[0]).toBe('updated');
    expect(JSON.parse(localStorage.getItem('key')!)).toBe('updated');
  });
});
Pro Tip: When testing hooks that depend on browser APIs (localStorage, IntersectionObserver, etc.), mock them in a beforeEach block or in your Jest setup file. For IntersectionObserver, create a mock class that stores callbacks and lets you trigger them manually.

FAQ

Can a custom hook return JSX?

Technically yes, but you should avoid it. A hook that returns JSX is doing two jobs — logic and rendering. Keep hooks as pure logic extractors and return data/handlers. If you need to share UI + behavior, use a component instead.

When should I extract logic into a custom hook vs a utility function?

Extract to a custom hook when the logic uses React primitives (useState, useEffect, useRef, useCallback, useMemo, other hooks). If the logic is pure computation with no side effects and no state, a regular utility function is simpler and easier to test.

Why does my useEffect inside a custom hook run more times than expected?

Usually the dependency array contains an object or function created inside the component that gets a new reference on every render. Stabilize it with useMemo or useCallback at the call site, or restructure the hook to derive the value internally.

How do I share state between multiple instances of the same custom hook?

By default, each hook call gets its own isolated state — like calling useState twice. To share state across hook instances, lift it out of the hook into a Zustand store, React context, or a module-level variable (for simple non-reactive sharing).

Should I memoize the return value of a custom hook?

Only memoize when you have a measured performance problem. Premature memoization adds complexity and can introduce stale closure bugs. The rule: if the hook returns an object or array and that return value is used as a dependency in another hook downstream, wrapping with useMemo prevents infinite loops — but fix the architecture first if possible.