React & Frontend

15 React Hooks That Will Make You a 10x Developer in 2026

React hooks changed the game when they launched. But most developers still only use useState and useEffect. Here are 15 built-in and custom hooks — from React 19's useOptimistic to battle-tested patterns like useDebounce — that will eliminate boilerplate, boost performance, and level up your React applications.

Yacine Kahlerras
Yacine KahlerrasSoftware Engineer, Platform & UX at TurboDocx
March 15, 202620 min read

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.

HookCategorySourceWhen To Use
useTransitionPerformanceBuilt-inKeep UI responsive during heavy state updates
useDeferredValuePerformanceBuilt-inDeprioritize non-critical re-renders
useOptimisticUXReact 19Instant feedback before server confirms
useFormStatusFormsReact 19Access parent form submission state
useIdSSRBuilt-inUnique IDs that match server & client
useDebouncePerformanceCustomDelay execution until input settles
useLocalStoragePersistenceCustomSync state with browser storage
useMediaQueryResponsiveCustomReact to viewport or device changes
usePreviousStateCustomCompare current vs. previous value
useIntersectionObserverUXCustomLazy load, infinite scroll, animations
useCopyToClipboardUXCustomOne-click copy with status feedback
useToggleStateCustomBoolean switches (modals, dark mode)
useOnlineStatusNetworkCustomDetect connectivity changes
useIdleUXCustomDetect user inactivity for security/UX
useFetchDataCustomDeclarative 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 priority
setQuery(e.target.value);
// Filtering 10k items is low priority — won't block the input
startTransition(() => {
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 ones
const 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 update
await 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 component
function 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.

See it in action

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 silence
function 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 automatically
const [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 queries
function 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 changes
function 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 view
function 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 };
}
// Usage
function 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 connection
function 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 dashboards
function AdminPanel() {
const isIdle = useIdle(5 * 60 * 1000); // 5 minutes
if (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 fetching
function 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

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.

Yacine Kahlerras
Yacine KahlerrasSoftware Engineer, Platform & UX at TurboDocx