React ships with more hooks than most developers realize. And the custom hook ecosystem has evolved to solve patterns you encounter in every project — debouncing inputs, persisting state, detecting viewport changes. Yet most codebases still reinvent these wheels with ad-hoc useEffect spaghetti.
This guide covers 15 hooks that matter in 2026 — five built-in hooks you're probably underusing, plus ten custom hooks that belong in every React project. Whether you're building a high-throughput API integration or a complex document workflow, these patterns will make your code cleaner, faster, and easier to maintain.
| Hook | Category | Source | When To Use |
|---|---|---|---|
| useTransition | Performance | Built-in | Keep UI responsive during heavy state updates |
| useDeferredValue | Performance | Built-in | Deprioritize non-critical re-renders |
| useOptimistic | UX | React 19 | Instant feedback before server confirms |
| useFormStatus | Forms | React 19 | Access parent form submission state |
| useId | SSR | Built-in | Unique IDs that match server & client |
| useDebounce | Performance | Custom | Delay execution until input settles |
| useLocalStorage | Persistence | Custom | Sync state with browser storage |
| useMediaQuery | Responsive | Custom | React to viewport or device changes |
| usePrevious | State | Custom | Compare current vs. previous value |
| useIntersectionObserver | UX | Custom | Lazy load, infinite scroll, animations |
| useCopyToClipboard | UX | Custom | One-click copy with status feedback |
| useToggle | State | Custom | Boolean switches (modals, dark mode) |
| useOnlineStatus | Network | Custom | Detect connectivity changes |
| useIdle | UX | Custom | Detect user inactivity for security/UX |
| useFetch | Data | Custom | Declarative data fetching with loading/error |
Part 1: Built-in Hooks You're Probably Underusing
React ships with powerful hooks beyond useState and useEffect. React 19 added even more. These five built-in hooks solve problems that developers typically reach for third-party libraries to fix.
1. useTransition — Keep Your UI Buttery Smooth
Wraps a low-priority state update so React can interrupt it to handle urgent interactions like typing or clicking. The UI stays responsive while expensive re-renders happen in the background. Think of it as telling React: “this update can wait.”
When to use it
Filtering a 10,000-row data table, switching dashboard tabs with heavy charts, or updating a complex form without freezing the page. Any state update where the UI should remain interactive during processing.
function SearchResults() {const [query, setQuery] = useState("");const [isPending, startTransition] = useTransition();const [results, setResults] = useState<Item[]>([]);function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {// Typing stays instant — this update is high prioritysetQuery(e.target.value);// Filtering 10k items is low priority — won't block the inputstartTransition(() => {setResults(filterItems(e.target.value));});}return (<><input value={query} onChange={handleSearch} />{isPending && <Spinner />}<ItemList items={results} /></>);}
Pro tip
useTransition gives you an isPending boolean — use it to show a subtle spinner without blocking the entire UI. Users perceive the app as faster even though the same work is happening.
2. useDeferredValue — Let React Prioritize What Matters
Creates a “stale” copy of a value that React updates at lower priority. While useTransition wraps the setter, useDeferredValue wraps the consumer — perfect when you don't control the state update.
function AutocompleteSearch({ query }: { query: string }) {// React keeps showing stale results while computing new onesconst deferredQuery = useDeferredValue(query);const isStale = query !== deferredQuery;const suggestions = useMemo(() => filterSuggestions(deferredQuery),[deferredQuery]);return (<ul style={{ opacity: isStale ? 0.6 : 1 }}>{suggestions.map((item) => (<li key={item.id}>{item.label}</li>))}</ul>);}
Pro tip
Combine useDeferredValue with useMemo for maximum impact. The deferred value prevents re-computation, and useMemo caches the result — double protection against jank.
3. useOptimistic — Make Server Actions Feel Instant
New in React 19. Shows an optimistic state immediately while a server action is in flight, then reconciles when the response arrives. If the action fails, React automatically rolls back — no try/catch gymnastics needed.
When to use it
Adding items to a cart, toggling likes, sending messages in a chat, or any interaction where waiting for the server feels sluggish. The UI shows the update immediately and reconciles when the server catches up.
function TodoList({ todos, addTodoAction }: Props) {const [optimisticTodos, addOptimistic] = useOptimistic(todos,(state, newTodo: string) => [...state,{ id: crypto.randomUUID(), text: newTodo, pending: true },]);async function handleSubmit(formData: FormData) {const text = formData.get("todo") as string;addOptimistic(text); // Instant UI updateawait addTodoAction(text); // Server catches up}return (<form action={handleSubmit}><input name="todo" /><button type="submit">Add</button>{optimisticTodos.map((todo) => (<div key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>{todo.text}</div>))}</form>);}
4. useFormStatus — The Missing Piece for Server-Side Forms
Another React 19 addition. Reads the submission status of a parent <form> — including pending, data, method, and action. No prop drilling or context required.
// Must be a CHILD of the <form> — won't work in the same componentfunction SubmitButton() {const { pending } = useFormStatus();return (<button type="submit" disabled={pending}>{pending ? "Submitting..." : "Submit"}</button>);}function ContactForm({ submitAction }: { submitAction: (fd: FormData) => Promise<void> }) {return (<form action={submitAction}><input name="email" type="email" required /><textarea name="message" required /><SubmitButton /></form>);}
Gotcha
The component using useFormStatus must be a child of the <form>. If you call it in the same component that renders the form, it won't see the submission state. Extract the button into its own component.
5. useId — SSR-Safe Unique IDs Without the Headache
Generates unique, deterministic IDs that match between server and client renders. Eliminates hydration mismatches from Math.random() or counter-based IDs — critical for any Next.js or SSR application.
function AccessibleField({ label }: { label: string }) {const id = useId();return (<div><label htmlFor={id}>{label}</label><input id={id} aria-describedby={`${id}-hint`} /><p id={`${id}-hint`}>This field is required.</p></div>);}// Server HTML: <input id=":r1:" />// Client HTML: <input id=":r1:" /> ← always matches, no hydration mismatch
Pro tip
Never use useId for list keys — it generates the same ID per component instance, not per list item. Use your data's unique identifier for keys instead.
We Use These Hooks Daily at TurboDocx
Our document automation platform is built with React 19 and Next.js. We use useTransition for filtering large template libraries, useOptimistic for instant feedback on document generation, and custom hooks like useDebounce across our TurboQuote CPQ module. These patterns keep our UI fast even when processing complex document workflows.
Part 2: Custom Hooks That Belong in Every Project
These 10 custom hooks solve patterns you encounter in virtually every React app. Copy them into your codebase, install them from a hooks library like usehooks-ts or ahooks, or use them as inspiration for your own abstractions.
6. useDebounce — Stop Hammering Your API
Delays updating a value until the input has been quiet for a specified duration. This prevents firing expensive operations — API calls, filtering, validation — on every keystroke. Essential for React performance optimization.
function useDebounce<T>(value: T, delayMs: number): T {const [debouncedValue, setDebouncedValue] = useState(value);useEffect(() => {const timer = setTimeout(() => setDebouncedValue(value), delayMs);return () => clearTimeout(timer);}, [value, delayMs]);return debouncedValue;}// Usage — search API only fires after 300ms of silencefunction SearchBar() {const [query, setQuery] = useState("");const debouncedQuery = useDebounce(query, 300);useEffect(() => {if (debouncedQuery) fetchResults(debouncedQuery);}, [debouncedQuery]);return <input value={query} onChange={(e) => setQuery(e.target.value)} />;}
Pro tip
300ms is the sweet spot for search inputs — fast enough to feel responsive, slow enough to batch keystrokes. For auto-save, try 1000–2000ms.
7. useLocalStorage — State That Survives Refreshes
Syncs React state with localStorage, persisting values across page refreshes and browser sessions. Handles SSR gracefully by falling back to the initial value on the server.
function useLocalStorage<T>(key: string, initialValue: T) {const [stored, setStored] = 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 = useCallback((value: T | ((prev: T) => T)) => {setStored((prev) => {const next = value instanceof Function ? value(prev) : value;window.localStorage.setItem(key, JSON.stringify(next));return next;});},[key]);return [stored, setValue] as const;}// Usage — persists across sessions automaticallyconst [theme, setTheme] = useLocalStorage("theme", "light");
Pro tip
Wrap the initial read in a try/catch — localStorage can throw if the user has disabled it, if the stored JSON is corrupted, or in private browsing mode.
8. useMediaQuery — Responsive Logic in JavaScript
Subscribes to CSS media query changes in JavaScript, giving you a boolean you can use to conditionally render components, not just style them differently. Perfect for component composition patterns where layout changes structurally between breakpoints.
function useMediaQuery(query: string): boolean {const [matches, setMatches] = useState(() => {if (typeof window === "undefined") return false;return window.matchMedia(query).matches;});useEffect(() => {const mql = window.matchMedia(query);const handler = (e: MediaQueryListEvent) => setMatches(e.matches);mql.addEventListener("change", handler);return () => mql.removeEventListener("change", handler);}, [query]);return matches;}// Usage — render different layouts without CSS media queriesfunction Dashboard() {const isMobile = useMediaQuery("(max-width: 768px)");return isMobile ? <MobileNav /> : <Sidebar />;}
Pro tip
Use this for conditional rendering (showing/hiding entire components), not styling. For visual-only changes, CSS media queries are more performant since they don't trigger re-renders.
State, DOM & Interaction Hooks
9. usePrevious — Compare Current vs. Previous Values
Stores and returns the previous value of any variable using a ref. Since refs persist across renders without triggering re-renders, this is both efficient and side-effect free. Great for animating transitions, detecting direction of change, or logging state history.
function usePrevious<T>(value: T): T | undefined {const ref = useRef<T>(undefined);useEffect(() => {ref.current = value;});return ref.current;}// Usage — animate only when count actually changesfunction Counter({ count }: { count: number }) {const prevCount = usePrevious(count);const direction = prevCount !== undefined && count > prevCount ? "up" : "down";return (<motion.span key={count} animate={{ y: direction === "up" ? -10 : 10 }}>{count}</motion.span>);}
10. useIntersectionObserver — Lazy Loading Made Simple
Wraps the browser's IntersectionObserver API in a clean hook, telling you when an element is visible in the viewport. No scroll event listeners, no manual calculations. Essential for content-heavy applications with lots of images or dynamic sections.
function useIntersectionObserver(ref: RefObject<Element | null>,options?: IntersectionObserverInit) {const [isVisible, setIsVisible] = useState(false);useEffect(() => {const el = ref.current;if (!el) return;const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting),options);observer.observe(el);return () => observer.disconnect();}, [ref, options]);return isVisible;}// Usage — lazy load images when they scroll into viewfunction LazyImage({ src, alt }: { src: string; alt: string }) {const ref = useRef<HTMLDivElement>(null);const isVisible = useIntersectionObserver(ref, { threshold: 0.1 });return (<div ref={ref}>{isVisible ? <img src={src} alt={alt} /> : <Skeleton />}</div>);}
Pro tip
Set threshold: 0.1 for lazy loading (trigger as soon as 10% is visible) and threshold: 0.5 for analytics (count as “seen” when half is visible).
11. useCopyToClipboard — One-Click Copy with Feedback
Uses the modern navigator.clipboard API to copy text, then auto-resets a “copied” boolean after a timeout. Dead simple but saves you from re-implementing it in every project.
function useCopyToClipboard() {const [copied, setCopied] = useState(false);const copy = useCallback(async (text: string) => {await navigator.clipboard.writeText(text);setCopied(true);setTimeout(() => setCopied(false), 2000);}, []);return { copied, copy };}// Usagefunction ShareLink({ url }: { url: string }) {const { copied, copy } = useCopyToClipboard();return (<button onClick={() => copy(url)}>{copied ? "Copied!" : "Copy link"}</button>);}
12. useToggle — The Simplest Hook You'll Use the Most
Manages a boolean with toggle, setTrue, and setFalse functions. It's just three lines internally, but eliminates the (prev) => !prev boilerplate you write dozens of times per project.
function useToggle(initial = false) {const [value, setValue] = useState(initial);const toggle = useCallback(() => setValue((v) => !v), []);const setTrue = useCallback(() => setValue(true), []);const setFalse = useCallback(() => setValue(false), []);return { value, toggle, setTrue, setFalse } as const;}// Usage — modal, dark mode, sidebar, accordion...function SettingsPanel() {const darkMode = useToggle(false);const sidebar = useToggle(true);return (<><button onClick={darkMode.toggle}>{darkMode.value ? "Light mode" : "Dark mode"}</button><button onClick={sidebar.toggle}>{sidebar.value ? "Collapse" : "Expand"} sidebar</button></>);}
Pro tip
The setTrue and setFalse helpers are stable references (wrapped in useCallback), making them safe to pass as props without causing re-renders in memoized children.
Network, Activity & Data Fetching
13. useOnlineStatus — Know When Users Lose Connection
Listens to the browser's online/offline events and returns a reactive boolean. Essential for apps that need to handle intermittent connectivity — showing offline banners, queuing form submissions, or switching to cached data.
function useOnlineStatus() {const [isOnline, setIsOnline] = useState(typeof navigator !== "undefined" ? navigator.onLine : true);useEffect(() => {const goOnline = () => setIsOnline(true);const goOffline = () => setIsOnline(false);window.addEventListener("online", goOnline);window.addEventListener("offline", goOffline);return () => {window.removeEventListener("online", goOnline);window.removeEventListener("offline", goOffline);};}, []);return isOnline;}// Usage — show a banner when the user loses connectionfunction App() {const isOnline = useOnlineStatus();return !isOnline ? <Banner>You are offline</Banner> : <MainApp />;}
Key insight
navigator.onLine only detects whether the browser has a network connection — it can't tell if the internet is actually reachable. For critical apps, pair this with a lightweight ping to your API.
14. useIdle — Auto-Lock, Auto-Save, Auto-Logout
Tracks user activity (mouse, keyboard, scroll, touch) and returns true when the user has been inactive for a specified duration. Resets on any interaction. Critical for security-sensitive applications like digital signature platforms and admin dashboards.
function useIdle(timeoutMs = 30_000) {const [isIdle, setIsIdle] = useState(false);useEffect(() => {let timer: ReturnType<typeof setTimeout>;const events = ["mousemove", "keydown", "scroll", "touchstart"];const reset = () => {setIsIdle(false);clearTimeout(timer);timer = setTimeout(() => setIsIdle(true), timeoutMs);};reset();events.forEach((e) => document.addEventListener(e, reset));return () => {clearTimeout(timer);events.forEach((e) => document.removeEventListener(e, reset));};}, [timeoutMs]);return isIdle;}// Usage — auto-lock sensitive dashboardsfunction AdminPanel() {const isIdle = useIdle(5 * 60 * 1000); // 5 minutesif (isIdle) return <LockScreen />;return <DashboardContent />;}
15. useFetch — Declarative Data Fetching Without Boilerplate
Wraps the Fetch API with loading, error, and data states plus automatic AbortController cleanup. A lightweight alternative when you don't need a full data-fetching library like React Query or SWR.
function useFetch<T>(url: string) {const [data, setData] = useState<T | null>(null);const [error, setError] = useState<Error | null>(null);const [loading, setLoading] = useState(true);useEffect(() => {const controller = new AbortController();setLoading(true);fetch(url, { signal: controller.signal }).then((res) => {if (!res.ok) throw new Error(`HTTP ${res.status}`);return res.json() as Promise<T>;}).then(setData).catch((err) => {if (err.name !== "AbortError") setError(err);}).finally(() => setLoading(false));return () => controller.abort();}, [url]);return { data, error, loading };}// Usage — zero boilerplate data fetchingfunction UserProfile({ userId }: { userId: string }) {const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);if (loading) return <Spinner />;if (error) return <ErrorMessage error={error} />;return <ProfileCard user={data!} />;}
Key insight
The AbortController cleanup is crucial — without it, navigating away mid-request causes the dreaded “can't update state on unmounted component” error. Always clean up your fetches.
Hook Mistakes That Senior Developers Still Make
Knowing which hooks to use is only half the battle. Here are five patterns that cause bugs in production — and how to fix them.
1. Over-fetching in useEffect
Problem: Fetching data in useEffect without cleanup causes race conditions and memory leaks when the component unmounts mid-request.
Fix: Always return an AbortController cleanup. Or use a data-fetching library like React Query that handles this for you.
2. Missing dependency arrays
Problem: Omitting or lying about dependencies leads to stale closures — your hook reads outdated values without warning.
Fix: Trust the eslint-plugin-react-hooks exhaustive-deps rule. Restructure your code instead of suppressing the warning.
3. Creating objects in dependency arrays
Problem: Passing inline objects or arrays as dependencies triggers infinite re-render loops because {} !== {} on every render.
Fix: Hoist static data to module scope, wrap dynamic data in useMemo, or destructure to primitive values.
4. Using useEffect for derived state
Problem: Using useEffect to “sync” state that can be computed from existing state/props creates unnecessary render cycles.
Fix: Compute derived values during render. If expensive, wrap in useMemo. You rarely need useEffect for state transformations.
5. Not cleaning up event listeners
Problem: Forgetting cleanup in hooks that add event listeners causes memory leaks and phantom updates in long-lived apps.
Fix: Every addEventListener needs a matching removeEventListener in the cleanup function. The useMediaQuery and useIdle hooks above show the correct pattern.
Built-in vs Custom vs Library: How to Choose
Use Built-in Hooks When…
- React provides exactly what you need
- You want zero extra dependencies
- The hook is performance-critical (
useTransition,useDeferredValue) - You need SSR compatibility (
useId)
Write Custom Hooks When…
- You repeat the same pattern 3+ times
- The logic is specific to your domain
- You need fine-grained control over behavior
- You want to avoid third-party dependencies
Use a Library When…
- Edge cases are tricky (SSR, concurrent mode)
- You need battle-tested, well-typed implementations
- The hook saves significant dev time
- Libraries: usehooks-ts, ahooks, @uidotdev/usehooks
Building Custom Hooks for Document APIs
Custom hooks like useDebounce and useFetch are especially powerful when integrating with document-generation APIs. Imagine a useDocumentPreview hook that debounces template variable changes and fetches a live preview — or a useTemplateStatus hook that polls for generation completion.
The TurboDocx API and SDK pair naturally with this approach, giving workflow automators clean endpoints to wrap in composable hooks rather than embedding generation logic directly in components.
Related Resources
React Performance Optimization: The Complete Guide
Memoization, code splitting, list virtualization, and profiling techniques to make your React apps faster.
React Design Patterns: The Complete Guide
Compound components, render props, HOCs, and architecture patterns for scalable React apps.
TurboDocx for Developers
See how developers use our API and SDK to build document automation into their React applications.
API Integration Best Practices
Production-ready patterns for authentication, error handling, and rate limiting in React apps.
Build Faster React Apps with TurboDocx
Our API and SDK handle document generation server-side, keeping your React frontend lean. No heavy client-side processing — just fast, clean integrations.
