Skip to main content

Custom hooks

A grab-bag of production-ready React hooks — media queries, debouncing, click-outside, localStorage, keyboard shortcuts, and more. Copy the source, drop it into your app.

Overview

Six focused hooks, each solving a single problem without bloating your bundle.

useMediaQueryresponsive

Reactive breakpoint detection from any CSS media query string.

useDebounceperformance

Delay updating a value until a user has stopped typing or interacting.

useClickOutsideinteraction

Fire a callback when a click occurs outside a referenced element.

useKeyboardinteraction

Register global keyboard shortcuts with optional modifier keys.

useLocalStoragepersistence

useState-compatible hook backed by localStorage for persistent state.

useCopyToClipboardutility

Copy text to the clipboard with automatic copied feedback state.

Where to put hooks

Place custom hooks in src/hooks/ at the project root. Each hook lives in its own file (e.g. src/hooks/use-media-query.ts) and is imported only where needed. All hooks require 'use client' components since they access browser APIs.

useMediaQuery

Subscribe to any CSS media query and get a reactive boolean that updates when the viewport changes.

src/hooks/use-media-query.ts
typescript
import { useState, useEffect } from 'react';

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const mql = window.matchMedia(query);
    setMatches(mql.matches);

    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);

  return matches;
}
Usage example
tsx
import { useMediaQuery } from '@/hooks/use-media-query';

export function ResponsiveNav() {
  const isMobile  = useMediaQuery('(max-width: 768px)');
  const isTablet  = useMediaQuery('(max-width: 1024px)');
  const prefsDark = useMediaQuery('(prefers-color-scheme: dark)');

  if (isMobile) return <MobileNav />;
  if (isTablet) return <TabletNav />;
  return <DesktopNav />;
}
Parameters
NameTypeRequiredDescription
Any valid CSS media query string, e.g. "(max-width: 768px)" or "(prefers-reduced-motion: reduce)".

Returns

booleantrue when the query matches the current viewport, false otherwise. Updates reactively on resize.

useDebounce

Delay propagation of a frequently-changing value until the user stops updating it for a given number of milliseconds.

src/hooks/use-debounce.ts
typescript
import { useState, useEffect } from 'react';

export 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 example — debounced search
tsx
import { useState } from 'react';
import { useDebounce } from '@/hooks/use-debounce';

export function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);

  // Only fires an API call when the user pauses typing for 400ms
  useEffect(() => {
    if (!debouncedQuery) return;
    fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}
Parameters
NameTypeRequiredDescription
The value to debounce. Can be any type — string, number, object, etc.
Milliseconds to wait after the last change before updating the returned value.

Returns

T — the debounced value, updated only after the specified delay has elapsed with no new changes.

useClickOutside

Detect mouse clicks that occur outside a referenced DOM element and invoke a handler — perfect for closing dropdowns, modals, and popovers.

src/hooks/use-click-outside.ts
typescript
import { RefObject, useEffect } from 'react';

export function useClickOutside(
  ref: RefObject<HTMLElement | null>,
  handler: () => void
): void {
  useEffect(() => {
    const listener = (event: MouseEvent | TouchEvent) => {
      if (!ref.current || ref.current.contains(event.target as Node)) {
        return;
      }
      handler();
    };

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

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}
Usage example — dropdown
tsx
import { useRef, useState } from 'react';
import { useClickOutside } from '@/hooks/use-click-outside';

export function Dropdown() {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useClickOutside(ref, () => setOpen(false));

  return (
    <div ref={ref} className="relative">
      <button onClick={() => setOpen(!open)}>Options</button>
      {open && (
        <div className="absolute top-full mt-1 w-48 rounded-lg border bg-white shadow-lg">
          <button className="block w-full px-4 py-2 text-left text-sm">Edit</button>
          <button className="block w-full px-4 py-2 text-left text-sm">Delete</button>
        </div>
      )}
    </div>
  );
}
Parameters
NameTypeRequiredDescription
A React ref attached to the element you want to track. Clicks inside this element are ignored.
Callback fired when a click or touch occurs outside the referenced element.

Returns

void — this hook has no return value; it operates via side effects on the document.

useKeyboard

Register a global keyboard shortcut with optional Ctrl/Meta modifier support. Automatically removes the listener on unmount.

src/hooks/use-keyboard.ts
typescript
import { useEffect } from 'react';

interface KeyboardOptions {
  ctrl?: boolean;
  meta?: boolean;
  shift?: boolean;
  preventDefault?: boolean;
}

export function useKeyboard(
  key: string,
  handler: () => void,
  options: KeyboardOptions = {}
): void {
  useEffect(() => {
    const listener = (e: KeyboardEvent) => {
      const ctrlOk  = options.ctrl  ? e.ctrlKey  : true;
      const metaOk  = options.meta  ? e.metaKey  : true;
      const shiftOk = options.shift ? e.shiftKey : true;

      if (
        e.key.toLowerCase() === key.toLowerCase() &&
        ctrlOk && metaOk && shiftOk
      ) {
        if (options.preventDefault) e.preventDefault();
        handler();
      }
    };

    document.addEventListener('keydown', listener);
    return () => document.removeEventListener('keydown', listener);
  }, [key, handler, options]);
}
Usage example — Cmd+K search
tsx
import { useState } from 'react';
import { useKeyboard } from '@/hooks/use-keyboard';

export function AppShell() {
  const [searchOpen, setSearchOpen] = useState(false);
  const [cmdOpen, setCmdOpen] = useState(false);

  // Open search with Cmd+K (Mac) or Ctrl+K (Windows/Linux)
  useKeyboard('k', () => setSearchOpen(true), {
    meta: true,
    preventDefault: true,
  });

  // Close modals with Escape
  useKeyboard('Escape', () => {
    setSearchOpen(false);
    setCmdOpen(false);
  });

  return (
    <>
      <Shell />
      {searchOpen && <SearchDialog onClose={() => setSearchOpen(false)} />}
    </>
  );
}
Parameters
NameTypeRequiredDescription
The keyboard key to listen for. Matches e.key case-insensitively (e.g. "k", "Escape", "Enter").
Callback invoked when the key (and any configured modifiers) are pressed.
When true, requires Ctrl to be held.
When true, requires Meta (Cmd on Mac, Win on Windows) to be held.
When true, requires Shift to be held.
When true, calls e.preventDefault() to suppress the browser default action.

Returns

void — registers on mount and cleans up on unmount automatically.

useLocalStorage

A drop-in replacement for useState that persists the value to localStorage and syncs across browser tabs via the storage event.

src/hooks/use-local-storage.ts
typescript
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T) => 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;
    }
  });

  const setValue = (value: T) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.warn(`useLocalStorage: could not save key "${key}"`, error);
    }
  };

  // Sync across tabs
  useEffect(() => {
    const handler = (e: StorageEvent) => {
      if (e.key === key && e.newValue !== null) {
        try {
          setStoredValue(JSON.parse(e.newValue) as T);
        } catch {}
      }
    };
    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  }, [key]);

  return [storedValue, setValue];
}
Usage example — persisted theme preference
tsx
import { useLocalStorage } from '@/hooks/use-local-storage';

type Theme = 'light' | 'dark' | 'system';

export function ThemeSelector() {
  const [theme, setTheme] = useLocalStorage<Theme>('app-theme', 'system');

  return (
    <div className="flex gap-2">
      {(['light', 'dark', 'system'] as Theme[]).map((t) => (
        <button
          key={t}
          onClick={() => setTheme(t)}
          className={theme === t ? 'bg-brand-500 text-white' : 'text-muted'}
        >
          {t}
        </button>
      ))}
    </div>
  );
}
Parameters
NameTypeRequiredDescription
The localStorage key used to persist the value. Must be unique across your app.
Fallback value used when no stored value exists for the key. Can be any JSON-serializable type.

Returns

[T, (value: T) => void] — a tuple identical to useState: the current value and a setter that persists to localStorage.

useCopyToClipboard

Copy text to the system clipboard using the Clipboard API. Exposes a transient copied flag that automatically resets after 2 seconds.

src/hooks/use-copy-to-clipboard.ts
typescript
import { useState, useCallback } from 'react';

interface UseCopyToClipboard {
  copied: boolean;
  copy: (text: string) => Promise<void>;
}

export function useCopyToClipboard(resetDelay = 2000): UseCopyToClipboard {
  const [copied, setCopied] = useState(false);

  const copy = useCallback(async (text: string) => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      setTimeout(() => setCopied(false), resetDelay);
    } catch (error) {
      console.warn('useCopyToClipboard: clipboard write failed', error);
    }
  }, [resetDelay]);

  return { copied, copy };
}
Usage example — code block with copy button
tsx
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { HugeiconsIcon } from '@hugeicons/react';
import { Copy01Icon, CheckmarkCircle02Icon } from '@hugeicons/core-free-icons';

function CodeBlock({ code }: { code: string }) {
  const { copied, copy } = useCopyToClipboard();

  return (
    <div className="relative rounded-xl bg-neutral-950 p-4">
      <button
        onClick={() => copy(code)}
        className="absolute top-3 right-3 flex items-center gap-1.5 text-xs
                   text-white/40 hover:text-white/80 transition-colors"
      >
        <HugeiconsIcon
          icon={copied ? CheckmarkCircle02Icon : Copy01Icon}
          size={14}
        />
        {copied ? 'Copied!' : 'Copy'}
      </button>
      <pre className="font-mono text-sm text-white/80">{code}</pre>
    </div>
  );
}
Parameters (constructor)
NameTypeRequiredDescription
Milliseconds before copied resets back to false. Defaults to 2000ms.

Returns

An object with two properties:

copiedboolean — true for resetDelay ms after a successful copy, then reverts to false.
copy(text: string) => Promise<void> — async function that writes the given text to the clipboard.

Best Practices

Tips for using and extending these hooks correctly.

Memoize handlers

Wrap callbacks passed to useClickOutside and useKeyboard in useCallback to prevent unnecessary listener re-registrations.

SSR guard in useMediaQuery

The hook initialises to false on the server. This means the first render is always "does not match" — add suppressHydrationWarning if needed.

Scope localStorage keys

Prefix keys with your app name (e.g. "myapp:theme") to avoid collisions with third-party scripts using the same window.

Avoid stale closures

Always list handler in the effect dependency array, or the hook captures a stale reference. The implementations above do this correctly.

Cleanup is automatic

All hooks in this collection return a cleanup function from useEffect. You do not need to manually remove listeners on unmount.

Generic types for useDebounce

Pass the type explicitly when TypeScript cannot infer it: useDebounce<SearchFilters>(filters, 300).

Related