Every time I start a new React project, I copy the same 5 hooks. Not from a library — from my own collection, battle-tested across 15+ production apps.
These aren't clever abstractions. They're boring, reliable utilities that eliminate the same bugs I've fixed dozens of times. Senior engineers don't write more code. They carry better defaults.
Here are the 5, with full TypeScript implementations you can copy today.
1. useDebounce — Stop Hammering Your API
The most common performance bug in React: firing an API call on every keystroke. Search inputs, autocomplete fields, filter forms — they all need debouncing.
import { useState, useEffect } from 'react';
function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
Usage:
function SearchBar() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Why not use lodash.debounce? Because lodash.debounce returns a function, which means you need useCallback + useRef to use it correctly in React. This hook works with React's mental model — it debounces a value, not a function. The component re-renders only when the debounced value actually changes.
Production tip: I use 300ms for search inputs, 500ms for auto-save, and 150ms for filter dropdowns. These aren't magic numbers — they're the result of user testing across multiple products.
2. usePrevious — Know Where You Came From
React doesn't give you a built-in way to access the previous value of a prop or state. But you need it constantly — for animations, comparison logic, and tracking changes.
import { useRef, useEffect } from 'react';
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
Usage:
function PriceDisplay({ price }: { price: number }) {
const previousPrice = usePrevious(price);
const direction = previousPrice !== undefined
? price > previousPrice ? 'up' : price < previousPrice ? 'down' : 'same'
: 'same';
return (
<span className={`price price--${direction}`}>
${price.toFixed(2)}
{direction === 'up' && ' ↑'}
{direction === 'down' && ' ↓'}
</span>
);
}
Why a ref and not state? Setting state would cause a re-render, which would update the "previous" value, which would cause another re-render — infinite loop. useRef updates synchronously without triggering re-renders.
Real-world use cases: I use this for price change animations (stocks, crypto), step-by-step form navigation (tracking which step the user came from), and undo functionality (storing the last N values with a small wrapper).
3. useLocalStorage — State That Survives Refresh
useState resets on page refresh. For user preferences, form drafts, and dark mode — you need persistence. But raw localStorage in React is full of pitfalls: SSR crashes, serialization bugs, tab sync issues.
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void, () => 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;
}
});
useEffect(() => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch {
console.warn(`Failed to save "${key}" to localStorage`);
}
}, [key, storedValue]);
const remove = useCallback(() => {
setStoredValue(initialValue);
if (typeof window !== 'undefined') {
window.localStorage.removeItem(key);
}
}, [key, initialValue]);
return [storedValue, setStoredValue, remove];
}
Usage:
function Settings() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
const [fontSize, setFontSize, resetFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
<input
type="range"
min={12}
max={24}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
<button onClick={resetFontSize}>Reset</button>
</div>
);
}
Three things most implementations get wrong:
-
SSR crashes.
localStoragedoesn't exist on the server. Thetypeof window === 'undefined'check is mandatory for Next.js and any SSR framework. -
No error handling.
localStoragecan throw — when storage is full, when the browser blocks it (private mode in some browsers), or when the value can't be serialized. Always wrap in try-catch. -
No removal. The
removefunction resets toinitialValueAND clears the key from storage. Without it, you end up with stale data that the user can't clear.
Important note for EU/GDPR: localStorage falls under the same regulations as cookies. In production, I always wrap this hook with a consent check — only read/write after the user accepts storage preferences.
4. useMediaQuery — Responsive Logic Without CSS
CSS handles responsive styles. But sometimes you need responsive logic: different components, different data fetching strategies, different interaction patterns based on screen size.
import { useState, useEffect } from 'react';
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
Usage:
function Dashboard() {
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
return (
<div>
{isMobile ? <MobileNav /> : <DesktopSidebar />}
<Chart animated={!prefersReducedMotion} />
</div>
);
}
Why not just use CSS? Three real scenarios where I need this hook:
- Conditional rendering. Mobile shows a bottom sheet; desktop shows a sidebar. These are different components, not different styles.
- Different data loading. On mobile, load 10 items per page. On desktop, load 25. This is logic, not layout.
-
Accessibility queries.
prefers-reduced-motionandprefers-color-schemeaffect behavior, not just styles. Should I autoplay this animation? Depends on the user's OS setting.
Pro tip: Create named breakpoints as constants instead of repeating magic strings:
const breakpoints = {
mobile: '(max-width: 768px)',
tablet: '(min-width: 769px) and (max-width: 1024px)',
desktop: '(min-width: 1025px)',
reducedMotion: '(prefers-reduced-motion: reduce)',
darkMode: '(prefers-color-scheme: dark)',
} as const;
// Usage: const isMobile = useMediaQuery(breakpoints.mobile);
5. useAbortController — Cancel Requests on Unmount
The most underrated hook. Every fetch call inside a useEffect should be abortable. Without it, you get state updates on unmounted components, race conditions on fast navigation, and memory leaks.
import { useRef, useEffect, useCallback } from 'react';
function useAbortController() {
const controllerRef = useRef<AbortController | null>(null);
const getSignal = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.abort();
}
controllerRef.current = new AbortController();
return controllerRef.current.signal;
}, []);
useEffect(() => {
return () => {
controllerRef.current?.abort();
};
}, []);
return { getSignal };
}
Usage:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const { getSignal } = useAbortController();
useEffect(() => {
const signal = getSignal();
fetch(`/api/users/${userId}`, { signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Failed to fetch user:', err);
}
});
}, [userId, getSignal]);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
What getSignal() does:
- Aborts any in-flight request from the previous call
- Creates a fresh
AbortController - Returns the new signal
This handles both unmount (cleanup effect aborts) and dependency changes (new call aborts the previous one). No race conditions. No stale data.
Why not just create the controller inside useEffect? You can, but then every effect needs its own abort boilerplate. This hook centralizes the pattern and makes it one line: const signal = getSignal().
Real scenario this prevents: User clicks through a list fast — clicks user 1, user 2, user 3. Without abort, all three requests are in flight. If request 1 returns last (network timing), user 3's profile shows user 1's data. Classic race condition that's invisible in dev but breaks production.
Bonus: How I Organize These
I keep these hooks in a /hooks directory with barrel exports:
src/
└── hooks/
├── useDebounce.ts
├── usePrevious.ts
├── useLocalStorage.ts
├── useMediaQuery.ts
├── useAbortController.ts
└── index.ts
Each hook has its own file. The index.ts re-exports everything. When I start a new project, I literally copy this folder.
No npm package. No dependencies. No versioning headaches. Just 5 files, ~150 lines total, zero external dependencies.
Key Takeaways
-
useDebounce— debounce values, not functions. Works naturally with React's render model. -
usePrevious— useuseRefto avoid infinite re-render loops. Essential for animations and change tracking. -
useLocalStorage— handle SSR, errors, and removal. Don't forget GDPR compliance. -
useMediaQuery— responsive logic for conditional rendering, data loading, and accessibility. -
useAbortController— prevent race conditions and memory leaks. Every fetch needs an abort signal.
These aren't exciting. They're not clever. They're just the boring infrastructure that prevents the same 5 bugs in every project. Copy them, adapt them, and move on to the interesting problems.
I share daily tips on React, TypeScript, and AI-augmented development on Twitter/X. Let's connect on LinkedIn — this post started there and your comments made it better.
Originally published on my Hashnode blog. Follow me for more AI + Architecture content.
Top comments (0)