DEV Community

Alex Rogov
Alex Rogov

Posted on • Originally published at alexrogov.hashnode.dev

5 Custom Hooks I Copy to Every React Project

Every time I start a new React project, I copy the same 5 hooks. Not from a library — from my own collection, battle-tested across 15+ production apps.

These aren't clever abstractions. They're boring, reliable utilities that eliminate the same bugs I've fixed dozens of times. Senior engineers don't write more code. They carry better defaults.

Here are the 5, with full TypeScript implementations you can copy today.

1. useDebounce — Stop Hammering Your API

The most common performance bug in React: firing an API call on every keystroke. Search inputs, autocomplete fields, filter forms — they all need debouncing.

import { useState, useEffect } from 'react';

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

  return debouncedValue;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function SearchBar() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Enter fullscreen mode Exit fullscreen mode

Why not use lodash.debounce? Because lodash.debounce returns a function, which means you need useCallback + useRef to use it correctly in React. This hook works with React's mental model — it debounces a value, not a function. The component re-renders only when the debounced value actually changes.

Production tip: I use 300ms for search inputs, 500ms for auto-save, and 150ms for filter dropdowns. These aren't magic numbers — they're the result of user testing across multiple products.

2. usePrevious — Know Where You Came From

React doesn't give you a built-in way to access the previous value of a prop or state. But you need it constantly — for animations, comparison logic, and tracking changes.

import { useRef, useEffect } from 'react';

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

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function PriceDisplay({ price }: { price: number }) {
  const previousPrice = usePrevious(price);

  const direction = previousPrice !== undefined
    ? price > previousPrice ? 'up' : price < previousPrice ? 'down' : 'same'
    : 'same';

  return (
    <span className={`price price--${direction}`}>
      ${price.toFixed(2)}
      {direction === 'up' && ''}
      {direction === 'down' && ''}
    </span>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why a ref and not state? Setting state would cause a re-render, which would update the "previous" value, which would cause another re-render — infinite loop. useRef updates synchronously without triggering re-renders.

Real-world use cases: I use this for price change animations (stocks, crypto), step-by-step form navigation (tracking which step the user came from), and undo functionality (storing the last N values with a small wrapper).

3. useLocalStorage — State That Survives Refresh

useState resets on page refresh. For user preferences, form drafts, and dark mode — you need persistence. But raw localStorage in React is full of pitfalls: SSR crashes, serialization bugs, tab sync issues.

import { useState, useEffect, useCallback } from 'react';

function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
  const [storedValue, setStoredValue] = useState<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;
    }
  });

  useEffect(() => {
    if (typeof window === 'undefined') return;
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch {
      console.warn(`Failed to save "${key}" to localStorage`);
    }
  }, [key, storedValue]);

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

  return [storedValue, setStoredValue, remove];
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function Settings() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
  const [fontSize, setFontSize, resetFontSize] = useLocalStorage('fontSize', 16);

  return (
    <div>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        {theme === 'light' ? '🌙' : '☀️'}
      </button>
      <input
        type="range"
        min={12}
        max={24}
        value={fontSize}
        onChange={(e) => setFontSize(Number(e.target.value))}
      />
      <button onClick={resetFontSize}>Reset</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Three things most implementations get wrong:

  1. SSR crashes. localStorage doesn't exist on the server. The typeof window === 'undefined' check is mandatory for Next.js and any SSR framework.
  2. No error handling. localStorage can throw — when storage is full, when the browser blocks it (private mode in some browsers), or when the value can't be serialized. Always wrap in try-catch.
  3. No removal. The remove function resets to initialValue AND clears the key from storage. Without it, you end up with stale data that the user can't clear.

Important note for EU/GDPR: localStorage falls under the same regulations as cookies. In production, I always wrap this hook with a consent check — only read/write after the user accepts storage preferences.

4. useMediaQuery — Responsive Logic Without CSS

CSS handles responsive styles. But sometimes you need responsive logic: different components, different data fetching strategies, different interaction patterns based on screen size.

import { useState, useEffect } from 'react';

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState<boolean>(() => {
    if (typeof window === 'undefined') return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    if (typeof window === 'undefined') return;

    const mediaQuery = window.matchMedia(query);
    setMatches(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setMatches(event.matches);
    };

    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [query]);

  return matches;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function Dashboard() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');

  return (
    <div>
      {isMobile ? <MobileNav /> : <DesktopSidebar />}
      <Chart animated={!prefersReducedMotion} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why not just use CSS? Three real scenarios where I need this hook:

  1. Conditional rendering. Mobile shows a bottom sheet; desktop shows a sidebar. These are different components, not different styles.
  2. Different data loading. On mobile, load 10 items per page. On desktop, load 25. This is logic, not layout.
  3. Accessibility queries. prefers-reduced-motion and prefers-color-scheme affect behavior, not just styles. Should I autoplay this animation? Depends on the user's OS setting.

Pro tip: Create named breakpoints as constants instead of repeating magic strings:

const breakpoints = {
  mobile: '(max-width: 768px)',
  tablet: '(min-width: 769px) and (max-width: 1024px)',
  desktop: '(min-width: 1025px)',
  reducedMotion: '(prefers-reduced-motion: reduce)',
  darkMode: '(prefers-color-scheme: dark)',
} as const;

// Usage: const isMobile = useMediaQuery(breakpoints.mobile);
Enter fullscreen mode Exit fullscreen mode

5. useAbortController — Cancel Requests on Unmount

The most underrated hook. Every fetch call inside a useEffect should be abortable. Without it, you get state updates on unmounted components, race conditions on fast navigation, and memory leaks.

import { useRef, useEffect, useCallback } from 'react';

function useAbortController() {
  const controllerRef = useRef<AbortController | null>(null);

  const getSignal = useCallback(() => {
    if (controllerRef.current) {
      controllerRef.current.abort();
    }
    controllerRef.current = new AbortController();
    return controllerRef.current.signal;
  }, []);

  useEffect(() => {
    return () => {
      controllerRef.current?.abort();
    };
  }, []);

  return { getSignal };
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const { getSignal } = useAbortController();

  useEffect(() => {
    const signal = getSignal();

    fetch(`/api/users/${userId}`, { signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error('Failed to fetch user:', err);
        }
      });
  }, [userId, getSignal]);

  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
Enter fullscreen mode Exit fullscreen mode

What getSignal() does:

  1. Aborts any in-flight request from the previous call
  2. Creates a fresh AbortController
  3. Returns the new signal

This handles both unmount (cleanup effect aborts) and dependency changes (new call aborts the previous one). No race conditions. No stale data.

Why not just create the controller inside useEffect? You can, but then every effect needs its own abort boilerplate. This hook centralizes the pattern and makes it one line: const signal = getSignal().

Real scenario this prevents: User clicks through a list fast — clicks user 1, user 2, user 3. Without abort, all three requests are in flight. If request 1 returns last (network timing), user 3's profile shows user 1's data. Classic race condition that's invisible in dev but breaks production.

Bonus: How I Organize These

I keep these hooks in a /hooks directory with barrel exports:

src/
└── hooks/
    ├── useDebounce.ts
    ├── usePrevious.ts
    ├── useLocalStorage.ts
    ├── useMediaQuery.ts
    ├── useAbortController.ts
    └── index.ts
Enter fullscreen mode Exit fullscreen mode

Each hook has its own file. The index.ts re-exports everything. When I start a new project, I literally copy this folder.

No npm package. No dependencies. No versioning headaches. Just 5 files, ~150 lines total, zero external dependencies.

Key Takeaways

  • useDebounce — debounce values, not functions. Works naturally with React's render model.
  • usePrevious — use useRef to avoid infinite re-render loops. Essential for animations and change tracking.
  • useLocalStorage — handle SSR, errors, and removal. Don't forget GDPR compliance.
  • useMediaQuery — responsive logic for conditional rendering, data loading, and accessibility.
  • useAbortController — prevent race conditions and memory leaks. Every fetch needs an abort signal.

These aren't exciting. They're not clever. They're just the boring infrastructure that prevents the same 5 bugs in every project. Copy them, adapt them, and move on to the interesting problems.


I share daily tips on React, TypeScript, and AI-augmented development on Twitter/X. Let's connect on LinkedIn — this post started there and your comments made it better.


Originally published on my Hashnode blog. Follow me for more AI + Architecture content.

Top comments (0)