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.

Share this code profile
Last updated: Oct 10, 2025