Every React tutorial has a section on custom hooks that shows you how to extract a useState + useEffect combo into a useWindowSize hook. Cool. And then you go back to your real app and wonder why you keep rewriting the same 40-line form state management logic in every component. Custom hooks are supposed to solve this. Most blog posts don't get close to the actually useful ones.
We've been building Next.js apps and templates for a while now, and there's a small set of hooks we reach for constantly — hooks that have survived actual production traffic, actual edge cases, and actual 2am debugging sessions. This is that list.
useDebounce — But Do It Right
Yes, everyone has a useDebounce. Most of them are wrong. The classic mistake is debouncing the callback instead of the value, which means you get stale closures biting you when the dependency array isn't set up carefully. Here's the version that's saved us more than once:
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 — search input that doesn't hammer your API
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
// This only fires 300ms after the user stops typing
fetchResults(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}The key insight: debounce the value, not the function. You get a stable output you can put in dependency arrays without thinking about it. The component that calls useDebounce doesn't care about timers — it just gets a value that lags behind the input by N milliseconds.
useLocalStorage — With Hydration Safety
useLocalStorage is another one where every implementation you find online has a hydration bug. You render on the server, localStorage doesn't exist, you get a mismatch, React yells at you, and you spend 20 minutes confused before realizing what's happening. Here's the version that actually works in Next.js:
import { useState, useEffect, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
// Always start with initialValue to avoid hydration mismatch
const [storedValue, setStoredValue] = useState<T>(initialValue);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
// Only runs on client, after hydration
try {
const item = window.localStorage.getItem(key);
if (item !== null) {
setStoredValue(JSON.parse(item));
}
} catch (error) {
console.warn(`useLocalStorage: error reading key "${key}"`, error);
}
setIsHydrated(true);
}, [key]);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.warn(`useLocalStorage: error setting key "${key}"`, error);
}
},
[key, storedValue]
);
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.warn(`useLocalStorage: error removing key "${key}"`, error);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue, isHydrated] as const;
}The isHydrated flag is the part most implementations skip. It lets you conditionally render things that depend on localStorage state — like a theme toggle — only after the client has loaded the real value. Otherwise you get a flash of the wrong state on every page load, which looks amateur. We also expose removeValue because you'll need it and you don't want to call setValue(undefined) and pretend that's the same thing.
useClickOutside — For Dropdowns That Actually Close
You've written this logic inline in a dropdown component. Then you needed it in a tooltip. Then a modal. Then a color picker. Then you copied it four times and all four had slightly different bugs. Extract it:
import { useEffect, RefObject } from 'react';
export function useClickOutside(
ref: RefObject<HTMLElement | null>,
handler: (event: MouseEvent | TouchEvent) => void
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if clicking the element itself or its children
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};
// mousedown, not click — feels more responsive
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// Usage
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useClickOutside(dropdownRef, () => setIsOpen(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <div className="dropdown-menu">...</div>}
</div>
);
}Small but important: we use mousedown instead of click. Click fires after mousedown and mouseup complete, so if the user presses down on a button inside the dropdown, moves slightly, and releases outside, you get a weird state. mousedown is more decisive — the moment the finger goes down outside your element, you know the intent.
useAsync — Taming Promise State Without a Library
React Query and SWR are great. But sometimes you're calling a one-off async operation — submitting a form, running a bulk action, triggering an export — and you don't need a full caching layer. You just need loading, error, and data state without writing it from scratch every time:
import { useState, useCallback } from 'react';
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
export function useAsync<T, Args extends unknown[]>(
asyncFn: (...args: Args) => Promise<T>
) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
const execute = useCallback(
async (...args: Args) => {
setState({ status: 'loading' });
try {
const data = await asyncFn(...args);
setState({ status: 'success', data });
return data;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setState({ status: 'error', error });
throw error; // Re-throw so callers can also handle it
}
},
[asyncFn]
);
const reset = useCallback(() => setState({ status: 'idle' }), []);
return { ...state, execute, reset };
}
// Usage
function ExportButton() {
const { status, execute } = useAsync(exportUserData);
return (
<button
onClick={() => execute(userId)}
disabled={status === 'loading'}
>
{status === 'loading' ? 'Exporting...' : 'Export Data'}
</button>
);
}The discriminated union type for state is doing a lot of work here. When status is 'success', TypeScript knows data exists. When it's 'error', it knows error exists. You never need to null-check both. The re-throw on errors is intentional — you might want the component to catch it too, and swallowing it in the hook would make that impossible.
usePrevious — Detect What Changed
This one looks like a toy until you need it, and then you need it three times in a row. Knowing what a value was on the previous render lets you do things like animate between states, track changes for undo/redo, or fire effects only when a value increases versus decreases:
import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}); // No dependency array — runs after every render
return ref.current;
}
// Practical example: show a +/- indicator on a score
function ScoreDisplay({ score }: { score: number }) {
const previousScore = usePrevious(score);
const delta =
previousScore !== undefined ? score - previousScore : 0;
return (
<div>
<span>{score}</span>
{delta !== 0 && (
<span className={delta > 0 ? 'text-green-500' : 'text-red-500'}>
{delta > 0 ? '+' : ''}{delta}
</span>
)}
</div>
);
}The no-dependency-array useEffect is the trick — it updates the ref after every render, which means during the current render, ref.current still holds the previous value. It's one of those patterns where the timing of React's rendering model actually works in your favor.
useMediaQuery — SSR-Safe Responsive Logic
Tailwind handles most responsive design, but sometimes you need responsive behavior in JavaScript — different data to fetch, different component trees, conditional rendering that CSS can't handle. This is the version that won't explode during SSR:
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
// Modern API
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
}, [query]);
return matches;
}
// Convenience wrappers for common breakpoints
export function useIsMobile() {
return useMediaQuery('(max-width: 768px)');
}
export function useIsDesktop() {
return useMediaQuery('(min-width: 1024px)');
}
export function usePrefersDark() {
return useMediaQuery('(prefers-color-scheme: dark)');
}
// Usage
function Navigation() {
const isMobile = useIsMobile();
return isMobile ? <MobileNav /> : <DesktopNav />;
}The default of false for SSR is a deliberate choice — on the server, we have no idea about the viewport, so we default to the non-mobile state. This means mobile users see a brief flash of desktop UI before hydration. If that's unacceptable, handle it with CSS first and reach for useMediaQuery only when you truly need JavaScript logic.
A Few Rules We've Learned
Beyond the specific hooks, here's what we've learned about writing hooks that don't become liabilities six months later:
- Name them after what they do, not what they use. useUserPermissions is better than useQueryWithAuthCheck. The name should tell you what you get back, not how it works internally.
- Return objects, not arrays, once you have more than two return values. Arrays force destructuring with position-dependent naming. Objects let you pick what you need.
- Handle cleanup. If your hook sets up a subscription, listener, or timer, always return a cleanup function from useEffect. Memory leaks in production are invisible until they're catastrophic.
- Don't over-abstract. If a hook is used in one place, it's probably not worth extracting yet. Wait until you're copying the same logic somewhere else — that's the real signal.
- Keep external dependencies out of the hook when possible. A hook that accepts a callback as a parameter is more reusable than one that imports a specific API client directly.
The last point is worth expanding on. We've seen codebases where hooks are tightly coupled to the fetching library, the state management library, and the toast library — all at once. When any one of those changes, the hook needs a rewrite. Hooks that accept callbacks or configuration stay flexible.
Where These Live in Our Projects
We keep all shared hooks in a /hooks directory at the project root, each in its own file with its own tests. When you're shopping for a solid starting structure, the templates on peal.dev ship with this setup already in place — the folder conventions, TypeScript config, and a handful of these production hooks already wired up, so you're not starting from a blank canvas.
One thing we got burned on early: putting hooks in a single hooks/index.ts barrel file. It sounds convenient until you have 30 hooks and every import triggers the whole barrel to parse. Individual files with named exports, imported directly, is slower to type but better for build performance and for your own sanity when you're trying to find something.
The best custom hook is the one that makes you forget it's there. If you're constantly looking at the hook implementation to remember what it returns, the API isn't clear enough.
If there's one thing to take away here: custom hooks aren't about being clever. They're about not rewriting the same logic for the fourth time at 11pm when you're already tired. The hooks above aren't impressive. They're just honest, boring, useful code that earns its place in every project we ship.
