DevLift
Back to Blog

React: 10 Custom Hooks You'll Use Daily

useDebounce, useLocalStorage, useMediaQuery, and seven more custom hooks that solve the same problems in every React project — with production-ready TypeScript implementations.

Admin
August 2, 20267 min read2 views

React: 10 Custom Hooks You'll Use Daily

Every React project I've worked on ends up with a /hooks folder that contains the same dozen utilities. Not because developers copy them blindly, but because the same problems keep showing up: debouncing inputs, persisting state to localStorage, detecting media query breakpoints, cleaning up event listeners. These are the hooks that don't ship with React but should.

This guide covers the 10 custom hooks I reach for in almost every project. Each one solves a real problem with minimal code, and each one has traps that are worth knowing before you hit them.

The Problem

Here's what code looks like before you extract these patterns:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
 
  // Debouncing inlined with the effect
  useEffect(() => {
    const timer = setTimeout(() => {
      if (!query) return;
      setLoading(true);
      fetch(`/api/search?q=${query}`)
        .then(r => r.json())
        .then(data => {
          setResults(data);
          setLoading(false);
        });
    }, 300);
    return () => clearTimeout(timer);
  }, [query]);
 
  // Media query logic also inlined
  const [isMobile, setIsMobile] = useState(false);
  useEffect(() => {
    const mql = window.matchMedia('(max-width: 768px)');
    setIsMobile(mql.matches);
    const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, []);
 
  // And localStorage sync, also inlined
  useEffect(() => {
    localStorage.setItem('lastQuery', query);
  }, [query]);
 
  // ... rendering
}

This component owns logic it shouldn't care about. Debouncing, media queries, and localStorage sync are infrastructure — they belong in hooks you import, not logic you paste.

The 10 Hooks

1. useDebounce

The one you'll use on every search input, autocomplete, and form field that triggers an API call.

import { useState, useEffect } from 'react';
 
export function useDebounce<T>(value: T, delay: number = 300): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
 
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer); // <-- clears on value or delay change
  }, [value, delay]);
 
  return debouncedValue;
}

Usage:

const debouncedQuery = useDebounce(query, 300);
 
useEffect(() => {
  if (!debouncedQuery) return;
  fetchResults(debouncedQuery);
}, [debouncedQuery]);

The key: the cleanup function runs before the next effect fires. If the user types a new character within 300ms, the previous timer is cancelled. Only the last value in a burst gets through.

2. useLocalStorage

Persists state to localStorage and survives page reloads. The tricky part is SSR and corrupted storage.

import { useState, useEffect } from 'react';
 
export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue; // <-- SSR guard
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue; // corrupted JSON or disabled localStorage
    }
  });
 
  const setValue = (value: T | ((prev: T) => T)) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch {
      console.warn(`useLocalStorage: failed to set key "${key}"`);
    }
  };
 
  return [storedValue, setValue] as const;
}

The try/catch blocks are not defensive paranoia — localStorage throws in private browsing on some browsers, when storage is full, and when the stored value was serialized by a different version of your app.

3. useMediaQuery

Bridges CSS media queries with React state. You need this when the difference between breakpoints changes component structure, not just styles.

import { useState, useEffect } from 'react';
 
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia(query).matches;
  });
 
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const mql = window.matchMedia(query);
    setMatches(mql.matches); // <-- re-read on mount AND whenever `query` changes
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);
 
  return matches;
}

That setMatches(mql.matches) line inside the effect is not redundant with the lazy initialiser. Without it, changing the query argument re-subscribes to the new media query but keeps reporting the old query's result until a change event happens to fire — which may be never. It also fixes the SSR case, where the initialiser is forced to guess false.

Usage:

const isMobile = useMediaQuery('(max-width: 768px)');
 
return isMobile ? <MobileNav /> : <DesktopNav />;

Use this for structural decisions. For pure styling differences, CSS media queries are faster and don't cause re-renders.

4. useAsync

Wraps any async function with loading, error, and value state. This is the general-purpose version of useFetch that works for any async operation.

import { useState, useCallback, useEffect, useRef } from 'react';
 
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; value: T }
  | { status: 'error'; error: Error };
 
export function useAsync<T>(asyncFn: () => Promise<T>, immediate = false) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
 
  // Latest-callback ref: callers almost always pass an inline arrow, so we must
  // NOT put asyncFn in a dependency array. See the callout below.
  const fnRef = useRef(asyncFn);
  useEffect(() => {
    fnRef.current = asyncFn;
  }, [asyncFn]);
 
  const execute = useCallback(async () => {
    setState({ status: 'loading' });
    try {
      const value = await fnRef.current();
      setState({ status: 'success', value });
    } catch (err) {
      setState({ status: 'error', error: err instanceof Error ? err : new Error(String(err)) });
    }
  }, []); // stable forever
 
  useEffect(() => {
    if (immediate) execute();
  }, [execute, immediate]);
 
  return { ...state, execute };
}
⚠️

The obvious version of this hook — useCallback(..., [asyncFn]) with useEffect(..., [execute, immediate]) — is broken, and it fails in the worst possible way. Almost every call site passes an inline arrow (useAsync(() => fetchUserOrders(userId), true)), so asyncFn is a new reference on every render, execute is recreated, the effect's dependencies change, the effect fires, setState triggers a render, and round it goes. Mounting that version once under React 19.2.3 with no user interaction fired the async function continuously until the probe force-stopped it at 30 calls; React eventually aborts with "Maximum update depth exceeded". Point it at a real endpoint and you have a request loop against your own API. The ref keeps execute stable and the effect fires exactly once.

Usage:

const { status, value: orders, error, execute: refetch } = useAsync(
  () => fetchUserOrders(userId),
  true // run immediately on mount
);
 
if (status === 'loading') return <Spinner />;
if (status === 'error') return <ErrorMessage error={error} />;
if (status === 'success') return <OrderList orders={orders} />;

The discriminated union return type means TypeScript narrows correctly in each branch.

5. usePrevious

Returns the value from the previous render. Useful for animations, comparison logic, and detecting direction of change.

import { useRef, useEffect } from 'react';
 
export function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);
  useEffect(() => {
    ref.current = value; // <-- runs after render, so ref lags one cycle
  });
  return ref.current;
}
const previousCount = usePrevious(count);
const direction = count > (previousCount ?? 0) ? 'up' : 'down';

The useEffect runs without a dependency array, so it captures the value after every render. On the next render, ref.current is one render behind — exactly what you want.

6. useEventListener

Adds event listeners with automatic cleanup. Eliminates the boilerplate that causes memory leaks when people forget the cleanup.

import { useEffect, useRef } from 'react';
 
type EventMap = HTMLElementEventMap & WindowEventMap & DocumentEventMap;
 
export function useEventListener<K extends keyof EventMap>(
  eventName: K,
  handler: (event: EventMap[K]) => void,
  element?: HTMLElement | Window | Document | null
) {
  const savedHandler = useRef(handler);
 
  useEffect(() => {
    savedHandler.current = handler; // always current, no stale closure
  }, [handler]);
 
  useEffect(() => {
    // Resolve the default INSIDE the effect — effects don't run on the server.
    const target = element === undefined ? window : element;
    if (!target) return;
    const listener = (event: Event) => savedHandler.current(event as EventMap[K]);
    target.addEventListener(eventName, listener);
    return () => target.removeEventListener(eventName, listener);
  }, [eventName, element]);
}

The ref keeps the listener stable (no re-registration on each render) while always calling the latest version of the handler.

⚠️

Do not write element: ... = window as a default parameter. Default parameters are evaluated when the hook is called, which includes the server render — and on the server window doesn't exist, so the whole page render dies with ReferenceError: window is not defined. Confirmed by running the default-parameter version through renderToStaticMarkup. In a Next.js app this is a hard 500, not a warning. Resolve browser globals inside the effect, which only ever runs on the client.

React 19.2 ships useEffectEvent, which does the "always call the latest handler without re-subscribing" part for you and removes the need for the ref entirely.

7. useClickOutside

Detects clicks outside a component. Every dropdown, modal, and popover needs this.

import { useEffect, useRef } from 'react';
 
export function useClickOutside<T extends HTMLElement>(
  callback: () => void
): React.RefObject<T | null> {
  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;
}
const dropdownRef = useClickOutside<HTMLDivElement>(() => setIsOpen(false));
 
return (
  <div ref={dropdownRef}>
    {isOpen && <DropdownMenu />}
  </div>
);
⚠️

Wrap the callback in useCallback at the call site, or the effect will re-run on every render because the function reference changes. Measured: three renders of a component passing an inline callback produced three document.addEventListener('mousedown', ...) registrations.

Note the return type is React.RefObject<T | null>, not React.RefObject<T>. In @types/react 19, useRef<T>(null) is typed RefObject<T | null>; declaring the narrower type is a compile error.

8. useIntersectionObserver

Fires a callback when an element enters or leaves the viewport. The foundation for lazy loading, infinite scroll, and scroll-triggered animations.

import { useEffect, useRef, useState } from 'react';
 
interface UseIntersectionObserverOptions extends IntersectionObserverInit {
  freezeOnceVisible?: boolean;
}
 
export function useIntersectionObserver(
  options: UseIntersectionObserverOptions = {}
): [React.RefObject<HTMLDivElement | null>, boolean] {
  const { threshold = 0, root = null, rootMargin = '0px', freezeOnceVisible = false } = options;
  const ref = useRef<HTMLDivElement>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const frozen = useRef(false);
 
  useEffect(() => {
    const element = ref.current;
    if (!element || frozen.current) return;
 
    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
      if (freezeOnceVisible && entry.isIntersecting) {
        frozen.current = true;       // <-- stop observing after the first hit
        observer.disconnect();
      }
    }, { threshold, root, rootMargin });
 
    observer.observe(element);
    return () => observer.disconnect();
  }, [threshold, root, rootMargin, freezeOnceVisible]);
 
  return [ref, isIntersecting];
}

The freeze lives in a ref, not in the dependency array. Putting isIntersecting in the deps — the obvious way to write this — tears down and rebuilds the IntersectionObserver on every single visibility flip: measured three observer constructions and two disconnect() calls for two flips. A ref keeps the observer alive for its whole useful life.

function LazyImage({ src, alt }: { src: string; alt: string }) {
  const [ref, isVisible] = useIntersectionObserver({ freezeOnceVisible: true });
  return (
    <div ref={ref}>
      {isVisible ? <img src={src} alt={alt} /> : <div className="placeholder" />}
    </div>
  );
}

freezeOnceVisible: true is the option you want for lazy loading — once the image loads, you don't need to keep watching.

9. useToggle

Toggles a boolean. Trivially simple, but worth extracting because the setState(prev => !prev) pattern clutters component logic.

import { useState, useCallback } from 'react';
 
export function useToggle(initialValue = false): [boolean, () => void, (value: boolean) => void] {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue(v => !v), []);
  const set = useCallback((newValue: boolean) => setValue(newValue), []);
  return [value, toggle, set];
}
const [isOpen, toggleOpen, setOpen] = useToggle(false);
 
// Toggle: toggleOpen()
// Force close: setOpen(false)

The third return value (set) handles the case where you need to force a specific state, like closing a modal from a "Cancel" button without calling toggle twice.

10. useCopyToClipboard

Copies text to the clipboard and gives you success/error feedback. Every code snippet component, share button, and invite link needs this.

import { useState, useCallback, useEffect, useRef } from 'react';
 
export function useCopyToClipboard(resetDelay = 2000) {
  const [copied, setCopied] = useState(false);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
  // Clear the pending reset if the component unmounts first
  useEffect(() => () => {
    if (timerRef.current) clearTimeout(timerRef.current);
  }, []);
 
  const copy = useCallback(async (text: string) => {
    if (!navigator.clipboard) {
      console.warn('Clipboard API not available');
      return false;
    }
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      if (timerRef.current) clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => setCopied(false), resetDelay); // <-- auto-reset
      return true;
    } catch {
      setCopied(false);
      return false;
    }
  }, [resetDelay]);
 
  return { copied, copy };
}

The clearTimeout on unmount matters more than it looks. Copy a link, navigate away within the reset window, and the naked setTimeout version calls setCopied on an unmounted component. React 18 removed the warning that used to tell you about this, so on React 19 it is completely silent — verified: zero console output. A silent leak is still a leak.

function ShareButton({ inviteLink }: { inviteLink: string }) {
  const { copied, copy } = useCopyToClipboard();
  return (
    <button onClick={() => copy(inviteLink)}>
      {copied ? 'Copied!' : 'Copy Link'}
    </button>
  );
}

How It Flows

Rendering diagram...

Variations and Edge Cases

Debounce with a Cancel Function

Sometimes you want to cancel a pending debounce, like when the component unmounts mid-keystroke or the user clears the input:

export function useDebounce<T>(value: T, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
  const cancel = useCallback(() => {
    if (timerRef.current) clearTimeout(timerRef.current);
  }, []);
 
  useEffect(() => {
    timerRef.current = setTimeout(() => setDebouncedValue(value), delay);
    return () => cancel();
  }, [value, delay, cancel]);
 
  return { debouncedValue, cancel };
}

useRef<ReturnType<typeof setTimeout> | null>(null) — not useRef<ReturnType<typeof setTimeout>>(). @types/react 19 removed the zero-argument useRef overload; calling it with no argument is error TS2554: Expected 1 arguments, but got 0.

useLocalStorage with Cross-Tab Sync

If your app runs in multiple tabs, you want storage changes from one tab to propagate to others:

export function useLocalStorageSynced<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useLocalStorage(key, initialValue);
 
  useEffect(() => {
    const handler = (e: StorageEvent) => {
      if (e.key === key && e.newValue !== null) {
        setStoredValue(JSON.parse(e.newValue));
      }
    };
    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  }, [key]);
 
  return [storedValue, setStoredValue] as const;
}

The storage event only fires in other tabs — not the one that made the change — which is exactly the behavior you want.

useAsync with AbortController

Long-running fetches need cancellation when the component unmounts or the input changes before the request completes:

export function useAsyncAbortable<T>(
  asyncFn: (signal: AbortSignal) => Promise<T>,
  deps: React.DependencyList
) {
  const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
 
  useEffect(() => {
    const controller = new AbortController();
    setState({ status: 'loading' });
    asyncFn(controller.signal)
      .then(value => setState({ status: 'success', value }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState({ status: 'error', error: err });
        }
      });
    return () => controller.abort(); // <-- cancels in-flight request on cleanup
  }, deps); // eslint-disable-line react-hooks/exhaustive-deps
 
  return state;
}

When NOT to Use This

⚠️

Don't wrap every piece of component logic in a custom hook. If the logic isn't reused in at least two places, the hook is just indirection — it adds a layer of abstraction without reducing code. Keep it in the component.

Hooks have a real cost: they add indirection, they require understanding the hook's contract before you can understand the component, and they make debugging slightly harder because the call stack has more layers. That cost is worth it when the hook is used in five components. It's not worth it when it's used in one.

Also skip the abstraction when:

  • The async logic is component-specific. A one-off mutation that only this form uses doesn't need useAsync. Just inline the state.
  • You're fighting the hook rules. If you find yourself reaching for useRef to work around stale closure issues in your custom hook, that's a sign the abstraction is fighting React's model.
  • Your team isn't familiar with the patterns. A useIntersectionObserver hook that no one else on the team understands creates a hidden dependency that bites you at 2am during an incident.

The right library for these is usehooks-ts or ahooks if you want maintained, production-tested versions rather than copy-pasted implementations. For greenfield projects, copy the ones you need and own them — you'll want to adapt them to your specific constraints anyway.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read