Why hooks still matter in 2024
Hooks are no longer “new”—they’re how we write React. But the ecosystem and runtime have evolved: concurrent rendering (React 18), smarter suspense patterns, transitions for smoother UX, and new APIs like useId and useSyncExternalStore all change how we compose logic.
This post collects seven production-ready hook recipes that solve real problems you’ll run into daily: async data, debouncing, event stability, persistence, responsiveness, measurements, and state sharing. Each recipe includes a copy-pasteable implementation, when to use it, and best practices for 2024 React apps.
Tip: All recipes are framework-agnostic and work in plain React 18+. If you’re on an app framework (Next.js, Remix), you can still drop these in with minimal tweaks.
Recipe 1: useAsync — robust async logic with cancellation and “stale response” protection
When you need it
- Fetching or computing data in response to user input.
- Avoiding race conditions from slow responses overwriting newer ones.
- Canceling in-flight work on component unmount or parameter change.
The implementation
import { useCallback, useRef, useState } from "react";
/**
* useAsync
* - orchestrates async calls with cancellation and stale response protection
* - returns state + a run function; optionally auto-run with your own effect
*
* @param {(ctx: { signal: AbortSignal }, ...args:any[]) => Promise<any>} asyncFn
* Provide an async function that accepts { signal } for cancellation.
*/
export function useAsync(asyncFn) {
const [state, setState] = useState({
status: "idle", // "idle" | "loading" | "success" | "error"
data: null,
error: null,
});
const requestRef = useRef({ id: 0, controller: null });
const run = useCallback(async (...args) => {
// Cancel previous request
if (requestRef.current.controller) {
requestRef.current.controller.abort();
}
const controller = new AbortController();
const id = requestRef.current.id + 1;
requestRef.current = { id, controller };
setState((s) => ({ ...s, status: "loading", error: null }));
try {
const result = await asyncFn({ signal: controller.signal }, ...args);
// Ignore if a newer request started
if (requestRef.current.id !== id) return;
setState({ status: "success", data: result, error: null });
return result;
} catch (err) {
if (err && err.name === "AbortError") return; // ignore aborts
if (requestRef.current.id !== id) return; // ignore stale
setState({ status: "error", data: null, error: err });
throw err;
}
}, [asyncFn]);
const reset = useCallback(() => {
if (requestRef.current.controller) {
requestRef.current.controller.abort();
}
requestRef.current = { id: 0, controller: null };
setState({ status: "idle", data: null, error: null });
}, []);
return { ...state, run, reset, isLoading: state.status === "loading" };
}
How to use it
import { useEffect, useState, useDeferredValue } from "react";
async function fetchUsers({ signal }, q) {
const res = await fetch(`https://api.github.com/search/users?q=${q}`, { signal });
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.items;
}
function SearchUsers() {
const [query, setQuery] = useState("");
// Use a deferred value to keep UI snappy while you type quickly.
const deferredQuery = useDeferredValue(query);
const { data, error, isLoading, run, reset } = useAsync(fetchUsers);
useEffect(() => {
if (!deferredQuery) {
reset();
return;
}
// You can call run in effects or events
run(deferredQuery);
}, [deferredQuery, run, reset]);
return (
<div>
<input
placeholder="Search GitHub users"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{isLoading && <p>Loading…</p>}
{error && <p style={{ color: "red" }}>{error.message}</p>}
<ul>
{(data ?? []).map((u) => (
<li key={u.id}>{u.login}</li>
))}
</ul>
</div>
);
}
Best practices
- Pass an AbortSignal to your async function and respect it inside fetch/logic.
- Treat useAsync as orchestration; wrap your asyncFn in useCallback if it depends on props/state.
- Combine with useDeferredValue or useTransition for buttery UX during fast typing.
- Avoid putting run directly in render; call it in an effect or handler.
Recipe 2: useDebouncedValue — smooth out noisy inputs without stale bugs
When you need it
- Debounce user input before firing a request or expensive computation.
- Avoid effect cascades on every keystroke.
The implementation
import { useEffect, useRef, useState } from "react";
/**
* useDebouncedValue
* - returns a value that updates only after `delay` ms
* - includes cancel() and flush() helpers
*/
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
const timeoutRef = useRef(null);
const lastValueRef = useRef(value);
useEffect(() => {
lastValueRef.current = value;
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setDebounced(value);
timeoutRef.current = null;
}, delay);
return () => clearTimeout(timeoutRef.current);
}, [value, delay]);
const cancel = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Keep debounced as-is
};
const flush = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setDebounced(lastValueRef.current);
};
return [debounced, { cancel, flush }];
}
How to use it
function DebouncedSearch() {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 400);
useEffect(() => {
// Fetch with debouncedQuery here
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Debounced search"
/>
);
}
Debounce vs transitions vs deferred values
- Debounce controls when work fires. Great for network requests/thrashing.
- useDeferredValue keeps the UI responsive by rendering a lagging version of the value while input updates immediately.
- useTransition defers non-urgent state updates (e.g., filtering a big list) so typing doesn’t lag.
It’s common to combine: debounce for the network call, defer/transition for the UI.
Recipe 3: useEventCallback + useEventListener — stable handlers without re-subscribing
When you need it
- You attach event listeners or timers and don’t want to resubscribe on every render.
- You need the latest props/state inside a handler without bloating dependency arrays or capturing stale closures.
React’s experimental useEvent is not stable yet. This recipe gives you a production-ready equivalent.
The implementation
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
/**
* useEventCallback
* - returns a stable function identity that always calls latest callback
*/
export function useEventCallback(fn) {
const ref = useRef(fn);
// Use layout effect so ref updates before browser paints
useLayoutEffect(() => {
ref.current = fn;
});
return useCallback((...args) => {
return ref.current?.(...args);
}, []);
}
/**
* useEventListener
* - attaches an event listener with a stable handler
* - works with window, document, element, or custom EventTarget
*/
export function useEventListener(target, type, handler, options) {
const stableHandler = useEventCallback(handler);
useEffect(() => {
const el = typeof target === "function" ? target() : target;
if (!el?.addEventListener) return;
el.addEventListener(type, stableHandler, options);
return () => el.removeEventListener(type, stableHandler, options);
}, [target, type, stableHandler, options]);
}
How to use it
- Timers without stale closures:
function Stopwatch() {
const [ms, setMs] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// setState with functional update to avoid stale ms
setMs((t) => t + 10);
}, 10);
return () => clearInterval(id);
}, []);
return <div>{(ms / 1000).toFixed(2)}s</div>;
}
- Scroll listener that doesn’t resubscribe when state changes:
function ScrollTopIndicator() {
const [atTop, setAtTop] = useState(true);
useEventListener(window, "scroll", () => {
setAtTop(window.scrollY < 10);
}, { passive: true });
return <div>{atTop ? "At top" : "Scrolling…"}</div>;
}
Best practices
- Prefer functional setState inside handlers to avoid stale values.
- For performance-sensitive listeners, add { passive: true } when appropriate.
- If your target can be null (e.g., ref.current), pass a function: () => ref.current so the hook resolves it on every effect.
Recipe 4: useLocalStorageState — persisted state with SSR safety and cross-tab sync
When you need it
- Remember user preferences (theme, layout, dismissed banners).
- Persist form progress or filters.
- Keep tabs in sync when one tab changes a setting.
The implementation
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
const isBrowser = typeof window !== "undefined";
/**
* useLocalStorageState
* - SSR-safe: lazy initializes on client
* - Custom serialize/deserialize and cross-tab sync
*/
export function useLocalStorageState(
key,
initialValue,
{
serialize = JSON.stringify,
deserialize = JSON.parse,
sync = true, // listen to "storage" events
} = {}
) {
const initializer = useCallback(() => {
if (!isBrowser) return typeof initialValue === "function" ? initialValue() : initialValue;
try {
const raw = window.localStorage.getItem(key);
if (raw == null) {
return typeof initialValue === "function" ? initialValue() : initialValue;
}
return deserialize(raw);
} catch {
return typeof initialValue === "function" ? initialValue() : initialValue;
}
}, [key, initialValue, deserialize]);
const [state, setState] = useState(initializer);
// Write to localStorage when state changes
useEffect(() => {
if (!isBrowser) return;
try {
if (state === undefined) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, serialize(state));
}
} catch {
// Storage might be unavailable (private mode)
}
}, [key, state, serialize]);
// Cross-tab sync
useEffect(() => {
if (!isBrowser || !sync) return;
const onStorage = (e) => {
if (e.key !== key) return;
try {
if (e.newValue == null) {
setState(typeof initialValue === "function" ? initialValue() : initialValue);
} else {
setState(deserialize(e.newValue));
}
} catch {
// ignore bad JSON
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key, deserialize, initialValue, sync]);
const remove = useCallback(() => {
setState(undefined);
if (isBrowser) window.localStorage.removeItem(key);
}, [key]);
return [state, setState, { remove }];
}
How to use it
function ThemeToggle() {
const [theme, setTheme] = useLocalStorageState("theme", "system");
const isDark = theme === "dark" || (theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
useEffect(() => {
document.documentElement.dataset.theme = isDark ? "dark" : "light";
}, [isDark]);
return (
<div>
<button onClick={() => setTheme("light")}>Light</button>
<button onClick={() => setTheme("dark")}>Dark</button>
<button onClick={() => setTheme("system")}>System</button>
</div>
);
}
Best practices
- Always guard for SSR by reading localStorage lazily inside useState’s initializer.
- Provide a remove helper to clear the key.
- If you store objects, consider versioning your schema to handle breaking changes.
Recipe 5: useMediaQuery — responsive logic that respects user preferences
When you need it
- Respond to viewport size changes like “is mobile”.
- Respect user preference media queries (reduced motion, color scheme).
- Toggle UI behavior at breakpoints without CSS-only hacks.
The implementation
import { useEffect, useMemo, useSyncExternalStore } from "react";
const isBrowser = typeof window !== "undefined";
function subscribeToMediaQuery(query, callback) {
if (!isBrowser) return () => {};
const mql = window.matchMedia(query);
// Modern browsers support addEventListener('change'), older ones use addListener
const handler = () => callback();
if (mql.addEventListener) mql.addEventListener("change", handler);
else mql.addListener(handler);
return () => {
if (mql.removeEventListener) mql.removeEventListener("change", handler);
else mql.removeListener(handler);
};
}
function getSnapshotForQuery(query) {
if (!isBrowser) return false;
return window.matchMedia(query).matches;
}
/**
* useMediaQuery
* - uses useSyncExternalStore to work with concurrent rendering
*/
export function useMediaQuery(query) {
const getSnap = useMemo(() => getSnapshotForQuery.bind(null, query), [query]);
const subscribe = useMemo(() => subscribeToMediaQuery.bind(null, query), [query]);
return useSyncExternalStore(subscribe, getSnap, () => false);
}
How to use it
function ResponsivePanel() {
const isNarrow = useMediaQuery("(max-width: 768px)");
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
return (
<div style={{ transition: prefersReducedMotion ? "none" : "transform 300ms" }}>
{isNarrow ? "Stacked layout" : "Side-by-side layout"}
</div>
);
}
Best practices
- Prefer useSyncExternalStore for external sources (window, media) to align with React 18’s concurrent rendering.
- For server rendering, the provided third argument to useSyncExternalStore is the server snapshot; we return false to avoid hydration mismatches.
- Keep queries centralized (e.g., a breakpoints module) to avoid typos.
Recipe 6: useMeasure — precise element sizing with ResizeObserver
When you need it
- You need the size of an element to position a popover, virtualize, or animate.
- CSS alone can’t tell you when content wraps or resizes.
The implementation
import { useLayoutEffect, useState, useCallback } from "react";
/**
* useMeasure
* - returns a ref callback and a DOMRect-like bounds object
* - uses ResizeObserver; falls back gracefully
*/
export function useMeasure() {
const [node, setNode] = useState(null);
const [bounds, setBounds] = useState({ x: 0, y: 0, width: 0, height: 0 });
const ref = useCallback((el) => {
setNode(el || null);
}, []);
useLayoutEffect(() => {
if (!node) return;
let frame = 0;
const update = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const rect = node.getBoundingClientRect();
setBounds({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
});
};
update();
let ro;
if ("ResizeObserver" in window) {
ro = new ResizeObserver(update);
ro.observe(node);
} else {
// Fallback: listen to window resize
window.addEventListener("resize", update);
}
return () => {
cancelAnimationFrame(frame);
if (ro) ro.disconnect();
else window.removeEventListener("resize", update);
};
}, [node]);
return [ref, bounds];
}
How to use it
function Tooltip({ text, children }) {
const [targetRef, rect] = useMeasure();
return (
<span style={{ position: "relative" }}>
<span ref={targetRef}>{children}</span>
<span
role="tooltip"
style={{
position: "absolute",
top: rect.height + 8,
left: 0,
background: "#333",
color: "white",
padding: "4px 8px",
borderRadius: 4,
whiteSpace: "nowrap",
}}
>
{text}
</span>
</span>
);
}
Best practices
- Use useLayoutEffect for measurements to avoid visual flicker.
- Batch reads (getBoundingClientRect) and writes (setting style) thoughtfully to minimize layout thrashing.
- Don’t measure on every render; let ResizeObserver trigger updates.
Recipe 7: Tiny global store with useSyncExternalStore — share state without a heavy library
When you need it
- Share lightweight global state (auth, theme, feature flags) across components.
- Avoid prop drilling without committing to a full state library.
- Get correct behavior in concurrent rendering with fine-grained subscriptions.
The implementation
import { useMemo } from "react";
import { useSyncExternalStore } from "react";
/**
* Minimal store implementation with subscribe/get/set
*/
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
const get = () => state;
const set = (update) => {
const next =
typeof update === "function" ? update(state) : { ...state, ...update };
if (next === state) return;
state = next;
listeners.forEach((l) => l());
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { get, set, subscribe };
}
// Example: export a singleton store
export const appStore = createStore({ count: 0, theme: "light" });
// Selector-aware hook
export function useStore(selector = (s) => s) {
// getSnapshot: return a selected slice. If selection is a new object each time,
// React will still re-render when store updates. For big objects, memoize selectors.
const getSnapshot = () => selector(appStore.get());
return useSyncExternalStore(appStore.subscribe, getSnapshot, getSnapshot);
}
// Optional: tiny action helpers
export const actions = {
increment() {
appStore.set((s) => ({ ...s, count: s.count + 1 }));
},
setTheme(theme) {
appStore.set((s) => ({ ...s, theme }));
},
};
How to use it
function Counter() {
const count = useStore((s) => s.count);
return (
<div>
<button onClick={() => actions.increment()}>+</button>
<span style={{ margin: "0 8px" }}>{count}</span>
<button onClick={() => actions.increment()}>Again</button>
</div>
);
}
function ThemeBadge() {
const theme = useStore((s) => s.theme);
return <span>Theme: {theme}</span>;
}
function ThemeSwitch() {
const theme = useStore((s) => s.theme);
return (
<div>
<button
onClick={() => actions.setTheme(theme === "light" ? "dark" : "light")}
>
Toggle theme
</button>
</div>
);
}
Best practices
- Keep the store small. Co-locate big domain logic in features; expose only the minimal global surface.
- Select only what you need: useStore((s) => s.count) is more efficient than reading the whole object.
- If you compute derived data, memoize inside selector or precompute in store to avoid waste.
- For persistence, combine with useLocalStorageState or write to localStorage inside actions.
Practical guidance: transitions and deferred updates in 2024
Even though not individual “custom hooks,” transitions are essential to modern UX with hooks:
- useTransition: Mark state updates as non-urgent. Great for filtering large lists.
- useDeferredValue: Render a lagging value to keep inputs instantaneous.
Example pattern:
import { useMemo, useState, useTransition, useDeferredValue } from "react";
function FastFilter({ items }) {
const [q, setQ] = useState("");
const [isPending, startTransition] = useTransition();
const onChange = (e) => {
const next = e.target.value;
// Avoid blocking keystrokes by deferring expensive filter
startTransition(() => setQ(next));
};
// Or, use a deferred value:
const deferredQ = useDeferredValue(q);
const filtered = useMemo(() => {
const needle = deferredQ.toLowerCase();
return items.filter((i) => i.toLowerCase().includes(needle));
}, [items, deferredQ]);
return (
<div>
<input onChange={onChange} placeholder="Filter…" />
{isPending && <div>Updating…</div>}
<ul>{filtered.map((i) => <li key={i}>{i}</li>)}</ul>
</div>
);
}
Rules of thumb:
- For user-typed input feeding expensive renders: useTransition or useDeferredValue.
- For network calls: combine with debouncing (Recipe 2).
- Keep transitions focused—don’t wrap unrelated state updates.
Integration tips and gotchas
- Keep effects lean. Effects are for synchronization with outside systems, not for computing render output. Prefer useMemo and derived values for pure computation.
- Depend on what you use. For event handlers, useEventCallback keeps dependencies minimal. For effects, include all referenced values to avoid stale behavior.
- Abort long-running work in effects and during unmount (Recipe 1).
- Be SSR-aware. Guard window/document access and use lazy initializers.
- Names matter. Prefix your custom hooks with “use” and keep their contracts tight and predictable.
Testing your hooks
-
Test behavior, not implementation details:
- useAsync: simulate slow and fast responses, ensure stale results don’t overwrite new state, and that aborts are ignored.
- useDebouncedValue: use fake timers to advance time and verify flush/cancel.
- useLocalStorageState: mock localStorage; assert cross-tab sync via dispatching a storage event.
-
For components consuming hooks, rely on user-level interactions (React Testing Library) and assert rendered output, not internal state.
Copy/paste cheat sheet
- useAsync: cancelable, stale-safe async orchestrator with run() and reset().
- useDebouncedValue: delay value updates; includes cancel() and flush().
- useEventCallback: stable function identity with fresh closure.
- useEventListener: attach listeners without re-subscribing every render.
- useLocalStorageState: SSR-safe persistence + cross-tab sync.
- useMediaQuery: responsive logic via useSyncExternalStore.
- useMeasure: element size via ResizeObserver, returns [ref, bounds].
- useSyncExternalStore store: tiny global store with selectors and actions.
- Pair with transitions (useTransition) and deferred values (useDeferredValue) for responsive UX.
Final thoughts
React hooks shine when you compose small, well-scoped pieces. In 2024, the “gotchas” are mainly about concurrency-friendliness, SSR safety, and avoiding stale closures. The seven recipes above address those head-on with pragmatic, production-tested patterns. Start by dropping in one or two where you feel the most pain—async data and debounced input are excellent first wins—then grow your toolkit as needs arise.