DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on • Originally published at github.com

I put the 8 custom React hooks I re-copy into every project in one place — with live demos and the source

Every React project ends up with the same little hooks/ folder — a debounce, a localStorage wrapper, a click-outside. I got tired of re-copying them from old repos, so I put the eight I actually reuse into one place, each with a demo you can poke and the source one click away.

▶ Live demo: https://react-hooks-showcase.vercel.app/
Source (all in one file): https://github.com/dev48v/react-hooks-showcase/blob/main/src/hooks.ts

The eight: useDebounce, usePrevious, useLocalStorage, useMediaQuery, useOnClickOutside, useCopyToClipboard, useToggle, useIntersectionObserver. All dependency-free and typed. Here are the four with a subtlety worth knowing.

useDebounce — the cleanup is the whole trick

function useDebounce<T>(value: T, delay = 400): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);   // ← reset the timer on every change
  }, [value, delay]);
  return debounced;
}
Enter fullscreen mode Exit fullscreen mode

Each keystroke re-runs the effect, and the cleanup cancels the previous pending timeout. So the timer only fires once you stop typing. Fire your search/API call off the debounced value, not the raw one, and you go from one request per keystroke to one per pause.

usePrevious — why it returns last render's value

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  useEffect(() => { ref.current = value; }, [value]);
  return ref.current;
}
Enter fullscreen mode Exit fullscreen mode

It looks like it should return the current value, but it doesn't — because effects run after render. During this render ref.current still holds what the last effect wrote (the previous value); then the effect updates it for next time. That one-render lag is exactly what you want for "did this prop just change?" logic.

useMediaQuery — don't listen to resize

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
  useEffect(() => {
    const mq = window.matchMedia(query);
    const onChange = () => setMatches(mq.matches);
    mq.addEventListener("change", onChange);   // ← not window.resize
    setMatches(mq.matches);
    return () => mq.removeEventListener("change", onChange);
  }, [query]);
  return matches;
}
Enter fullscreen mode Exit fullscreen mode

The common mistake is a window.resize listener that re-renders on every pixel. matchMedia fires its change event only when the query result actually flips — one re-render at the breakpoint, not hundreds while dragging.

useOnClickOutside — every dropdown needs it

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (e) => {
      if (!ref.current || ref.current.contains(e.target)) return;
      handler(e);                     // click was outside → fire
    };
    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);
    return () => { /* remove both */ };
  }, [ref, handler]);
}
Enter fullscreen mode Exit fullscreen mode

Attach it to your menu's ref and close on outside click. Use mousedown (not click) so it fires before a re-render can move the target out from under the event, and add touchstart for mobile.

The remaining four — useLocalStorage (lazy init + error-swallowing writes), useCopyToClipboard, useToggle (memoized toggler), and useIntersectionObserver (lazy-load / infinite-scroll without scroll math) — are in the demo, each interactive with its source a click away.

Grab whichever you need; that's what they're for. If the collection saved you a trip to an old repo, a star helps others find it: https://github.com/dev48v/react-hooks-showcase

Top comments (0)