EmpireUI
Get Pro
← Blog8 min read#custom hooks#react#hooks

15 Custom React Hooks That Will Save You Hundreds of Lines

15 custom React hooks that eliminate repetitive boilerplate — from local storage sync to debounced inputs, with working code you can drop in today.

Code editor screen showing React JavaScript hooks and functions

Why You Keep Rewriting the Same Hook Logic

Every React project ends up with the same graveyard of copy-pasted patterns. The debounce hook from Stack Overflow circa 2022. The useLocalStorage you rewrote three times because the first two broke on SSR. The window resize listener that leaks memory in 40% of the codebases I've audited.

Honestly, this isn't laziness — it's the natural outcome of React's composability. The primitives are great, but they're low-level. You're supposed to build abstractions on top of them. The problem is most teams never formalize those abstractions into a shared hooks/ folder.

This article gives you 15 battle-tested hooks. Not toy examples — actual patterns that handle edge cases like SSR, stale closures, and cleanup. Grab what you need, drop it in, and stop solving the same problem twice.

Worth noting: if you're building UI-heavy apps and want polished components to pair these with, browse the components at Empire UI. Good hooks plus good components is a multiplier.

State and Storage Hooks

Start with useLocalStorage. It sounds trivial until you get a hydration mismatch on Next.js 14+ because you read localStorage during SSR. The fix is lazy initialization with a function fallback.

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T | ((val: 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 (error) {
      console.warn(`useLocalStorage error for key "${key}":`, error);
    }
  };

  return [storedValue, setValue] as const;
}

Pair this with useSessionStorage — identical shape, just swap the storage target. You'd be surprised how often you need per-tab state that doesn't survive refreshes. One more thing — add a useMediaQuery hook that reads window.matchMedia and subscribes to changes. It collapses 30 lines of boilerplate down to const isMobile = useMediaQuery('(max-width: 768px)'), and that single hook shows up in nearly every component I write.

The fourth in this group is usePrevious. Grab the previous render's value for comparison — dead simple with useRef, but everyone forgets the pattern when they need it. const prev = usePrevious(count) and you're done.

Performance Hooks: Debounce, Throttle, and Idle

The classic useDebounce is non-negotiable for search inputs. You don't want an API call on every keystroke — you want one call 300ms after the user stops typing. But most implementations miss the cleanup.

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;
}

That return in the effect is the whole trick. Without it, you get a cascade of stale updates when the user types fast. For scroll and resize handlers where you want periodic updates rather than a trailing one, swap to useThrottle — same shape, different timing logic using a ref-based lastRan timestamp.

In practice, useIdleCallback is the underused one. Wrap requestIdleCallback with a fallback to setTimeout(fn, 1) for Safari. Fire expensive work — analytics events, prefetching, saving drafts — when the browser is genuinely idle. It's the difference between a 60fps feel and a janky one on mid-range hardware.

Quick aside: these hooks pair beautifully with animated components. If you're building anything motion-heavy, check out the glassmorphism components on Empire UI — they're built to stay smooth under pressure.

DOM and Event Hooks

Five hooks that handle the DOM plumbing you always forget to clean up. useEventListener is first — a typed wrapper around addEventListener that attaches to any target (window, document, a ref) and cleans up automatically.

function useEventListener<K extends keyof WindowEventMap>(
  eventName: K,
  handler: (event: WindowEventMap[K]) => void,
  element: EventTarget = window
) {
  const savedHandler = useRef(handler);

  useLayoutEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    if (!element?.addEventListener) return;
    const listener = (event: Event) =>
      savedHandler.current(event as WindowEventMap[K]);
    element.addEventListener(eventName, listener);
    return () => element.removeEventListener(eventName, listener);
  }, [eventName, element]);
}

The useRef pattern here keeps the handler stable without stale closure problems — a detail that bites you hard when event handlers capture outdated state. Next: useIntersectionObserver for lazy loading and scroll-triggered animations. Wrap the native API, expose isIntersecting, and you've got infinite scroll in 10 lines of component code.

useClickOutside closes dropdowns and modals. useHover tracks mouse enter/leave on a ref. useFocus does the same for keyboard focus. All three follow the same pattern: attach via useEventListener, return a boolean. Together they handle 90% of interactive UI state without a single external dependency.

Look, you could reach for a 40kb interaction library. Or you could own this code directly. I know which I'd choose for anything shipping to production.

Async and Data-Fetching Hooks

Before you default to React Query for every async need, consider that a useFetch hook covers the simple 80% case with zero dependencies. Status, data, error — that's all most components need.

The critical piece is the abort controller. Without it you get the dreaded 'cannot update state on unmounted component' warning on every fast navigation. Add AbortController to the effect cleanup and it vanishes.

useAsync is the more general version — pass any async function, get back { status, value, error }. It doesn't care about fetch specifically. Use it for anything promise-shaped: IndexedDB reads, file processing, whatever. One more thing — add useInterval for polling. Set up setInterval in an effect, expose a reset function, and you have a controlled data refresh without any library overhead.

If you do eventually reach for something heavier, the MCP page on Empire UI shows how these patterns scale when you start wiring AI-powered components into your data flow.

Accessibility and UX Hooks

This section gets skipped and it shouldn't. useLockBodyScroll prevents background scroll when a modal is open — one useEffect that sets document.body.style.overflow = 'hidden' and cleans it up. Without it, modals feel broken on mobile.

useKeyPress listens for a specific key string. Pair it with useEventListener internally. Now you have keyboard shortcuts in three lines: const isOpen = useKeyPress('Escape'). That's it. And useCopyToClipboard wraps the Clipboard API with a fallback to document.execCommand for older browsers, returning [copiedText, copy] — the copy button on every code snippet you've ever seen is this hook.

The last one in this group is useReducedMotion. It reads the prefers-reduced-motion media query and returns a boolean. Pass it to your animation logic and you respect accessibility settings without rewriting a single animation. Worth noting: this is the minimum bar for accessible animated UIs in 2026, and it's 8 lines of code.

If you're building design-system-level components with these hooks, the box shadow generator and other tools on Empire UI can help you prototype the visual side while the hooks handle the behavior.

Organizing Your Hooks Folder

Don't scatter hooks across feature folders. A top-level src/hooks/ with a barrel export (index.ts) is the pattern that scales. Every hook gets its own file. Named exports only. No default exports.

Test hooks with @testing-library/react's renderHook. It's been stable since v13 and there's no excuse for untested hooks that manage timers or subscriptions. If your hook sets up an interval, your test should use jest.useFakeTimers() and advance them manually.

In practice, a well-organized hooks folder becomes one of the highest-value parts of your codebase. Junior devs discover patterns they didn't know existed. Senior devs stop duplicating work. Everyone stops Googling the same debounce implementation at 2am.

That said, hooks are tools, not architecture. Don't abstract everything into a hook just because you can. If the logic only lives in one component, keep it there. Extract when you actually reuse it — the second usage is the signal.

FAQ

What's the difference between a custom hook and a utility function?

A custom hook can call other hooks (useState, useEffect, etc.) — a utility function can't. If your abstraction needs React's lifecycle or state, it's a hook. Otherwise, keep it as a plain function.

Should I use a library like react-use or write my own hooks?

react-use is solid and covers most of these patterns, but you're adding a dependency to your bundle. Write your own for anything under 20 lines — you'll understand it better and debug it faster.

How do I test a hook that uses setTimeout or setInterval?

Use jest.useFakeTimers() before the test and jest.runAllTimers() or jest.advanceTimersByTime(300) to trigger the delayed behavior. renderHook from @testing-library/react handles the component lifecycle for you.

Can I use these hooks with Next.js App Router?

Yes, but hooks only run in Client Components. Add 'use client' at the top of any file that imports them. Hooks that touch window or localStorage also need SSR guards — the useLocalStorage example above already handles this.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Render Props in React 2026: Dead Pattern or Still Useful?React Compound Components Pattern: Flexible APIs Without Prop Hell10 Tailwind Component Patterns Every Developer Should KnowLanding Page Design Patterns in 2026: Above the Fold, Hero, CTA