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.
Reactive breakpoint detection from any CSS media query string.
Delay updating a value until a user has stopped typing or interacting.
Fire a callback when a click occurs outside a referenced element.
Register global keyboard shortcuts with optional modifier keys.
useState-compatible hook backed by localStorage for persistent state.
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.
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;
}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 />;
}| Name | Type | Required | Description |
|---|---|---|---|
| Any valid CSS media query string, e.g. "(max-width: 768px)" or "(prefers-reduced-motion: reduce)". |
Returns
boolean — true 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.
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;
}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..."
/>
);
}| Name | Type | Required | Description |
|---|---|---|---|
| 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.
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]);
}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>
);
}| Name | Type | Required | Description |
|---|---|---|---|
| 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.
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]);
}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)} />}
</>
);
}| Name | Type | Required | Description |
|---|---|---|---|
| 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.
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];
}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>
);
}| Name | Type | Required | Description |
|---|---|---|---|
| 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.
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 };
}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>
);
}| Name | Type | Required | Description |
|---|---|---|---|
| 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).