React7 min read
10 Custom React Hooks Worth Having (TypeScript Examples)
10 custom React hooks in TypeScript: useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useInterval and more, built for React 19 and the compiler.
The custom React hooks most apps end up needing are useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside, useEventListener, useInterval, useToggle, usePrevious, useCopyToClipboard and useOnlineStatus. Each one wraps a small piece of browser or timing logic that would otherwise be copied across components. Below are TypeScript versions of all ten, written for React 19.2+ so they use useSyncExternalStore and useEffectEvent where those fit, and stay compatible with the React Compiler.
Key takeaways
- A custom hook shares logic, not state. Two components calling
useToggle()get two separate booleans. - Subscriptions to browser state (media queries, online status, storage) belong in
useSyncExternalStore, notuseState+useEffect. useEffectEvent(stable in React 19.2) removes the old "store the callback in a ref" trick from hooks likeuseIntervalanduseEventListener.- Do not read or write
ref.currentduring render. The compiler's lint rules flag it, and it causes stale UI. - Every hook below is SSR-safe: browser APIs are touched only in effects, handlers or client snapshots.
Rules for writing custom hooks
Three rules keep custom hooks predictable:
- Name starts with
use. This is how the linter and the compiler know to apply the Rules of Hooks. - Return the smallest useful API. A tuple for value + setter (like
useState), an object when there are more than two things. - Don't hide effects that should be events. If a hook only reacts to a click, it probably should be a plain function. I go deeper on this in Common useEffect mistakes.
| Hook | Main building block | Typical use |
|---|---|---|
useDebounce |
useState + useEffect timer |
Search inputs |
useLocalStorage |
useSyncExternalStore |
Persisted preferences |
useMediaQuery |
useSyncExternalStore |
Responsive logic in JS |
useOnClickOutside |
useEffect + useEffectEvent |
Dropdowns, modals |
useEventListener |
useEffect + useEffectEvent |
Keyboard shortcuts, resize |
useInterval |
useEffect + useEffectEvent |
Polling, countdowns |
useToggle |
useState |
Open/close state |
usePrevious |
useState during render |
Animations, diffing |
useCopyToClipboard |
Clipboard API | Copy buttons |
useOnlineStatus |
useSyncExternalStore |
Offline banners |
1. useDebounce
Delays a fast-changing value until it stops changing for delay ms.
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}function ProductSearch() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 400);
// fetch with debouncedQuery, not query
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Pass primitives as value. An object created inline changes identity every render and resets the timer forever. If you use TanStack Query, put debouncedQuery in the query key and you get caching for free.
2. useLocalStorage
Most useLocalStorage examples use useState and only read storage once. That misses updates from other tabs and from other components using the same key. useSyncExternalStore fixes both.
import { useCallback, useMemo, useSyncExternalStore } from 'react';
const listeners = new Set<() => void>();
function subscribe(callback: () => void) {
listeners.add(callback);
window.addEventListener('storage', callback); // other tabs
return () => {
listeners.delete(callback);
window.removeEventListener('storage', callback);
};
}
function readRaw(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null; // storage blocked or unavailable
}
}
export function useLocalStorage<T>(key: string, initialValue: T) {
const raw = useSyncExternalStore(
subscribe,
() => readRaw(key),
() => null, // server snapshot
);
const value = useMemo<T>(() => {
if (raw === null) return initialValue;
try {
return JSON.parse(raw) as T;
} catch {
return initialValue;
}
}, [raw, initialValue]);
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const current = readRaw(key);
const prev = current === null ? initialValue : (JSON.parse(current) as T);
const resolved =
typeof next === 'function' ? (next as (prev: T) => T)(prev) : next;
try {
window.localStorage.setItem(key, JSON.stringify(resolved));
} catch {
// quota exceeded or storage blocked: keep the UI working
}
listeners.forEach((l) => l()); // same tab
},
[key, initialValue],
);
return [value, setValue] as const;
}const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');The snapshot is the raw string, which is a primitive, so useSyncExternalStore can compare it cheaply. Parsing happens in useMemo. Pass a primitive or a module-level constant as initialValue so the memo stays stable.
3. useMediaQuery
import { useCallback, useSyncExternalStore } from 'react';
export function useMediaQuery(query: string, serverFallback = false): boolean {
const subscribe = useCallback(
(callback: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', callback);
return () => mql.removeEventListener('change', callback);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => serverFallback,
);
}const isDesktop = useMediaQuery('(min-width: 1024px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');Use CSS for layout whenever you can. This hook is for logic that must differ in JavaScript, like rendering a drawer on mobile and a sidebar on desktop, or skipping an animation. The server fallback renders first, so pick the value that causes the least layout shift.
4. useOnClickOutside
import { useEffect, useEffectEvent, type RefObject } from 'react';
export function useOnClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: (event: PointerEvent) => void,
) {
const onOutside = useEffectEvent(handler);
useEffect(() => {
function listener(event: PointerEvent) {
const el = ref.current;
if (!el || el.contains(event.target as Node)) return;
onOutside(event);
}
document.addEventListener('pointerdown', listener);
return () => document.removeEventListener('pointerdown', listener);
}, [ref]);
}function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useOnClickOutside(ref, () => setOpen(false));
return (
<div ref={ref}>
<button onClick={() => setOpen((o) => !o)}>Menu</button>
{open && <ul role="menu">{/* items */}</ul>}
</div>
);
}pointerdown covers mouse, touch and pen in one listener. useEffectEvent means the effect does not re-subscribe when you pass a new inline handler each render. Note that content rendered in a portal is outside ref in the DOM, so clicks inside a portaled menu count as "outside". Attach the ref to the portal content in that case.
5. useEventListener
import { useEffect, useEffectEvent } from 'react';
export function useEventListener<K extends keyof WindowEventMap>(
type: K,
handler: (event: WindowEventMap[K]) => void,
capture = false,
) {
const onEvent = useEffectEvent(handler);
useEffect(() => {
const listener = (event: WindowEventMap[K]) => onEvent(event);
window.addEventListener(type, listener, capture);
return () => window.removeEventListener(type, listener, capture);
}, [type, capture]);
}useEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});The generic K extends keyof WindowEventMap gives you the correct event type: KeyboardEvent for keydown, UIEvent for resize, and so on. If you want more TypeScript patterns like this, see TypeScript for React developers.
6. useInterval
setInterval inside useEffect is a classic stale-closure trap. Before useEffectEvent, the fix was to keep the latest callback in a ref. Now it is simpler:
import { useEffect, useEffectEvent } from 'react';
export function useInterval(callback: () => void, delay: number | null) {
const onTick = useEffectEvent(callback);
useEffect(() => {
if (delay === null) return; // paused
const id = setInterval(() => onTick(), delay);
return () => clearInterval(id);
}, [delay]);
}function LiveScore({ matchId }: { matchId: string }) {
const [score, setScore] = useState<string>('');
const [paused, setPaused] = useState(false);
useInterval(async () => {
const res = await fetch(`/api/score/${matchId}`);
setScore(await res.text());
}, paused ? null : 15_000);
return (
<>
<p>{score}</p>
<button onClick={() => setPaused((p) => !p)}>{paused ? 'Resume' : 'Pause'}</button>
</>
);
}The callback always sees the latest matchId, and changing it does not restart the timer. Passing null pauses polling.
7. useToggle
import { useCallback, useState } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle, setOn] as const;
}Small, but it removes a lot of setOpen(o => !o) noise. The useCallback is there for apps without the React Compiler; with it enabled you can drop it. I explain why in React Compiler: do you still need useMemo and useCallback?.
8. usePrevious
The popular version writes to ref.current in an effect and reads it during render. That breaks the rule against reading refs during render, and the React Compiler's refs lint rule will flag it. The React docs show a state-based pattern instead:
import { useState } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const [current, setCurrent] = useState(value);
const [previous, setPrevious] = useState<T | undefined>(undefined);
if (!Object.is(value, current)) {
setPrevious(current);
setCurrent(value);
}
return previous;
}Calling setState during render is allowed when it is conditional and only updates the same component. React re-renders immediately, before children render, so there is no flash of stale UI. Use it for things like "price went up or down" indicators.
9. useCopyToClipboard
import { useEffect, useState } from 'react';
export function useCopyToClipboard(resetAfterMs = 2000) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const id = setTimeout(() => setCopied(false), resetAfterMs);
return () => clearTimeout(id);
}, [copied, resetAfterMs]);
async function copy(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
return true;
} catch {
setCopied(false);
return false;
}
}
return { copied, copy };
}function CopyLink({ url }: { url: string }) {
const { copied, copy } = useCopyToClipboard();
return <button onClick={() => copy(url)}>{copied ? 'Copied' : 'Copy link'}</button>;
}The Clipboard API only works in secure contexts (HTTPS or localhost), which is why the hook returns false on failure instead of throwing.
10. useOnlineStatus
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
export function useOnlineStatus(): boolean {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true,
);
}navigator.onLine being true only means the device has a network connection, not that your API is reachable. Treat it as a hint for UI (an "offline" banner, disabling a sync button) and still handle failed requests. This matters most in offline-first apps. Dashain Games, a PWA I built with nine browser games, is designed to keep working without a connection, and the PWA offline support guide covers the service worker side.
Common mistakes when writing custom hooks
- Sharing state by accident. Expecting two components that call
useLocalStorage('x')to share React state. They don't, which is exactly why hook #2 above keeps its own listener set and notifies it on every write. - Unstable
subscribefunctions. Definingsubscribeinline withoutuseCallbackmakesuseSyncExternalStoreresubscribe every render. - Inline objects as dependencies.
useDebounce({ q, page })never settles. Pass primitives. - Touching
windowduring render. It crashes server rendering. Use effects or a server snapshot. - Returning a new object every render from a hook whose result is used as an effect dependency. Return primitives or memoize the result.
FAQ
What makes a function a custom React hook?
A custom hook is a function whose name starts with use and that calls other hooks. It shares stateful logic, not state itself: every component that calls it gets its own independent state.
Should I use a hooks library or write my own?
For a handful of small hooks, writing your own keeps dependencies low and lets you match your app's rules. Reach for a library when you need many hooks or edge cases you do not want to maintain.
Are these hooks safe with server-side rendering?
Yes. Hooks that touch browser APIs either do it inside effects or use useSyncExternalStore with a server snapshot, so they render a predictable fallback on the server and update after hydration.
Do custom hooks work with the React Compiler?
Yes, as long as they follow the Rules of React. Avoid reading or writing ref.current during render, keep them pure, and the compiler will memoize them like any component.
Wrapping up
You do not need a library for most of these. Ten short files in a hooks/ folder, each with a clear job and proper cleanup, cover the bulk of what real apps need. Copy the ones you use, delete the ones you don't, and keep them small enough that a new teammate can read each one in a minute.
- #react
- #hooks
- #typescript
- #custom-hooks
Keep reading
React8 min read
React 19 Hooks: use, useActionState, useOptimistic Explained
React 19 hooks explained with TypeScript examples: use, useActionState, useOptimistic and useFormStatus, plus the gotchas that trip people up in real forms.
Read articleReact8 min read
React Compiler: Do You Still Need useMemo and useCallback?
React Compiler 1.0 memoizes components for you. Learn when useMemo and useCallback are still needed, how to enable it in Vite or Next.js 16, and what breaks.
Read articleReact7 min read
React Hook Form + Zod: Type-Safe Form Validation Guide
Learn React Hook Form with Zod 4 for type-safe form validation: zodResolver setup, inferred types, cross-field rules, field arrays and server errors.
Read article