Skip to content

React Custom Hooks I Actually Reach For in 2026

React Custom Hooks I Actually Reach For in 2026

Confession: there was a stretch in 2021 when I had a hooks/ folder with 18 files and most of them were doing things React already did better. I had a useToggle, a useCounter, a useArray. I had two competing useFetch implementations because I forgot the first one existed. Half of them were one-liner wrappers that hid more than they helped.

These days my custom hook list is shorter. Not because hooks are out of fashion, but because I figured out which ones earn their keep and which ones are just me showing off that I know hooks exist.

This is the list of custom hooks I actually reach for in 2026, why I keep each one, and the ones I quietly deleted.

A custom hook is a function. Don’t make it more than that.

The single biggest mistake I see in my old code is treating custom hooks like they were components. They aren’t. A hook is a regular function that happens to call other hooks. It doesn’t render anything, doesn’t memoize automatically, and doesn’t isolate state from anything else in the calling component.

The official React docs hammer this point and I still think they undersell it. If you’d write the logic as a plain function in a different language, write it as a plain function in React too. Only reach for use* when the body actually needs useState, useEffect, or some other hook.

That filter alone kills about half my old hooks. useToggle was just !current. useCounter was n + 1. Neither one needed to be a hook.

usePersistedState, the one that survived every refactor

This is the one I keep across projects without changing much.

import { useState, useEffect } from "react";

export function usePersistedState(key, initial) {
  const [value, setValue] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? JSON.parse(raw) : initial;
    } catch {
      return initial;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota exceeded or private mode, fail silently
    }
  }, [key, value]);

  return [value, setValue];
}

It looks small. The reason it earns its place is the failure handling. Every time I’ve shipped a hook that hit localStorage without a try/catch, Safari private mode found it within a week. I’ve debugged this exact bug three separate times before I made the pattern non-optional.

Use it for filter state, sidebar collapsed state, the last selected tab. Don’t use it for anything that should be on the server. I made that mistake once with “saved cart” and watched a customer’s cart from June 2023 reappear nine months later on a different device.

useDebouncedValue versus useDebouncedCallback, pick the right one

I had a single useDebounce for years that took a value and returned a debounced version. Fine for search inputs. Wrong for almost everything else.

The split that fixed it:

// useDebouncedValue: returns the latest value after `delay` ms of quiet
export function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

// useDebouncedCallback: returns a stable function that fires after quiet
export function useDebouncedCallback(fn, delay = 300) {
  const ref = useRef(fn);
  ref.current = fn;
  const timer = useRef(null);
  return useCallback((...args) => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => ref.current(...args), delay);
  }, [delay]);
}

The value version is for displaying. The callback version is for firing side effects like analytics or save-as-you-type. Mixing them up is how you end up with a search box that triggers four network requests per keystroke because you debounced the input value but not the request fired from onChange. Ask me how I know.

useFetch is dead to me

I had three different useFetch hooks in 2022 and they all eventually grew the same bugs: no cache, no dedupe, refetch storms on remount, and that special variety of stale-state bug where the user navigates away and back and sees data from a request that resolved into a component that already unmounted.

I deleted all of them when I moved data fetching to a query library. I wrote about why TanStack Query replaced most of my data hooks recently and the short version is: a query library is a hook. It just happens to be one written by someone with way more time and tests than I had.

If you’re rolling your own useFetch in 2026, you’re rewriting the worst version of a library you could install in two minutes. The one exception is hitting a single internal endpoint from a single component for a single feature and never again. For anything that gets reused, install TanStack Query and stop.

useEventListener and the cleanup people forget

Adding event listeners directly in a component is one of those things that looks fine in dev and leaks in production. The hook version makes the cleanup mandatory because the effect’s return is the cleanup.

export function useEventListener(target, event, handler) {
  const ref = useRef(handler);
  ref.current = handler;

  useEffect(() => {
    const el = target?.current ?? target ?? window;
    const listener = (e) => ref.current(e);
    el.addEventListener(event, listener);
    return () => el.removeEventListener(event, listener);
  }, [target, event]);
}

The bit people get wrong: capturing handler directly inside the effect. Then either you list handler in the dependency array and re-bind on every render, or you don’t and the listener fires with a stale closure forever. The ref trick fixes both: stable listener, always-fresh handler.

Use it for keydown, resize, scroll, visibilitychange. Don’t use it for events that have a React equivalent. onClick is still onClick.

useElementSize and the ResizeObserver gotcha

This one I use mostly in dashboards where some chart needs to know its container width to redraw.

export function useElementSize(ref) {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      setSize({ width, height });
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [ref]);

  return size;
}

The gotcha I missed for an embarrassingly long time: ResizeObserver fires before paint, so calling setState inside it can trigger a “ResizeObserver loop limit exceeded” warning if the new size causes another layout change that fires the observer again. Fix it by debouncing or by checking whether the size actually changed before calling setState. The cheap version is comparing rounded values.

What I stopped reaching for

A short list of hooks I wrote, used, and quietly deleted.

useToggle was almost always shorter as setOpen((o) => !o) inline. usePrevious I used once for a comparison that should have been a useEffect dependency. useMount and useUnmount are useEffect(fn, []) with extra steps, and worse they hide the dependency array so readers can’t tell the effect is intentional-empty. I had a useLocalStorage separate from usePersistedState for a while, same thing, two files, pick one. And every useApi wrapper around fetch eventually became the broken useFetch I described above.

I don’t think any of these were bad ideas the first time I wrote them. I think the pattern is that custom hooks reward extracting things you actually reuse three or more times. Anything I extracted “in case I need it later” became dead code I had to maintain.

When NOT to write a custom hook

The rule I use now, after getting it wrong for years: if I can write the logic as a pure function and call it inside a component, do that. If I can express the dependency chain with one useEffect and never read it twice in the same component, do that. The hook is only earned when the same chain of useState plus useEffect plus event listener shows up in two or three places and copying it the third time finally annoys me.

The other anti-pattern: hooks that pretend to be components. If your “hook” calls four other hooks, returns 12 things, and is only ever used by one component, you’ve written a component without the JSX. Refactor it into a real component with children, or into separate smaller hooks.

I cover a lot of this kind of “what I actually keep versus what I deleted” thinking in my day-to-day work. The through-line in most of it is that taste is mostly about what you stop doing, not what you start.

A small action for this week

Go look at your hooks/ folder. For each custom hook in there, ask: how many components actually import it? Two or fewer is your warning sign. Either inline it back into the components, or rewrite it as a plain utility function. You’ll lose maybe four files and gain back the ability to read your own code six months from now without doing archaeology.

That’s the whole list. Custom hooks are still one of the best things about React, but the version of me that wrote 18 of them was not paying for the maintenance, and the version of me writing five of them is.