{"id":312,"date":"2026-06-04T13:01:19","date_gmt":"2026-06-04T13:01:19","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-custom-hooks-i-actually-reach-for-2026\/"},"modified":"2026-06-04T13:01:19","modified_gmt":"2026-06-04T13:01:19","slug":"react-custom-hooks-i-actually-reach-for-2026","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-custom-hooks-i-actually-reach-for-2026\/","title":{"rendered":"React Custom Hooks I Actually Reach For in 2026"},"content":{"rendered":"<p>Confession: there was a stretch in 2021 when I had a <code>hooks\/<\/code> folder with 18 files and most of them were doing things React already did better. I had a <code>useToggle<\/code>, a <code>useCounter<\/code>, a <code>useArray<\/code>. I had two competing <code>useFetch<\/code> implementations because I forgot the first one existed. Half of them were one-liner wrappers that hid more than they helped.<\/p>\n<p>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.<\/p>\n<p>This is the list of custom hooks I actually reach for in 2026, why I keep each one, and the ones I quietly deleted.<\/p>\n<h2 id=\"a-custom-hook-is-a-function-dont-make-it-more-than-that\">A custom hook is a function. Don&rsquo;t make it more than that.<\/h2>\n<p>The single biggest mistake I see in my old code is treating custom hooks like they were components. They aren&rsquo;t. A hook is a regular function that happens to call other hooks. It doesn&rsquo;t render anything, doesn&rsquo;t memoize automatically, and doesn&rsquo;t isolate state from anything else in the calling component.<\/p>\n<p>The <a href=\"https:\/\/react.dev\/learn\/reusing-logic-with-custom-hooks\" rel=\"nofollow noopener\" target=\"_blank\">official React docs<\/a> hammer this point and I still think they undersell it. If you&rsquo;d write the logic as a plain function in a different language, write it as a plain function in React too. Only reach for <code>use*<\/code> when the body actually needs <code>useState<\/code>, <code>useEffect<\/code>, or some other hook.<\/p>\n<p>That filter alone kills about half my old hooks. <code>useToggle<\/code> was just <code>!current<\/code>. <code>useCounter<\/code> was <code>n + 1<\/code>. Neither one needed to be a hook.<\/p>\n<h2 id=\"usepersistedstate-the-one-that-survived-every-refactor\">usePersistedState, the one that survived every refactor<\/h2>\n<p>This is the one I keep across projects without changing much.<\/p>\n<pre><code class=\"language-jsx\">import { useState, useEffect } from &quot;react&quot;;\n\nexport function usePersistedState(key, initial) {\n  const [value, setValue] = useState(() =&gt; {\n    try {\n      const raw = localStorage.getItem(key);\n      return raw ? JSON.parse(raw) : initial;\n    } catch {\n      return initial;\n    }\n  });\n\n  useEffect(() =&gt; {\n    try {\n      localStorage.setItem(key, JSON.stringify(value));\n    } catch {\n      \/\/ quota exceeded or private mode, fail silently\n    }\n  }, [key, value]);\n\n  return [value, setValue];\n}\n<\/code><\/pre>\n<p>It looks small. The reason it earns its place is the failure handling. Every time I&rsquo;ve shipped a hook that hit <code>localStorage<\/code> without a try\/catch, Safari private mode found it within a week. I&rsquo;ve debugged this exact bug three separate times before I made the pattern non-optional.<\/p>\n<p>Use it for filter state, sidebar collapsed state, the last selected tab. Don&rsquo;t use it for anything that should be on the server. I made that mistake once with &ldquo;saved cart&rdquo; and watched a customer&rsquo;s cart from June 2023 reappear nine months later on a different device.<\/p>\n<h2 id=\"usedebouncedvalue-versus-usedebouncedcallback-pick-the-right-one\">useDebouncedValue versus useDebouncedCallback, pick the right one<\/h2>\n<p>I had a single <code>useDebounce<\/code> for years that took a value and returned a debounced version. Fine for search inputs. Wrong for almost everything else.<\/p>\n<p>The split that fixed it:<\/p>\n<pre><code class=\"language-jsx\">\/\/ useDebouncedValue: returns the latest value after `delay` ms of quiet\nexport function useDebouncedValue(value, delay = 300) {\n  const [debounced, setDebounced] = useState(value);\n  useEffect(() =&gt; {\n    const id = setTimeout(() =&gt; setDebounced(value), delay);\n    return () =&gt; clearTimeout(id);\n  }, [value, delay]);\n  return debounced;\n}\n\n\/\/ useDebouncedCallback: returns a stable function that fires after quiet\nexport function useDebouncedCallback(fn, delay = 300) {\n  const ref = useRef(fn);\n  ref.current = fn;\n  const timer = useRef(null);\n  return useCallback((...args) =&gt; {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = setTimeout(() =&gt; ref.current(...args), delay);\n  }, [delay]);\n}\n<\/code><\/pre>\n<p>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 <code>onChange<\/code>. Ask me how I know.<\/p>\n<h2 id=\"usefetch-is-dead-to-me\">useFetch is dead to me<\/h2>\n<p>I had three different <code>useFetch<\/code> 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.<\/p>\n<p>I deleted all of them when I moved data fetching to a query library. I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/tanstack-query-2026-what-i-reach-for-instead-of-useeffect\" rel=\"noopener\">why TanStack Query replaced most of my data hooks<\/a> 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.<\/p>\n<p>If you&rsquo;re rolling your own <code>useFetch<\/code> in 2026, you&rsquo;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 <a href=\"https:\/\/tanstack.com\/query\/latest\" rel=\"nofollow noopener\" target=\"_blank\">TanStack Query<\/a> and stop.<\/p>\n<h2 id=\"useeventlistener-and-the-cleanup-people-forget\">useEventListener and the cleanup people forget<\/h2>\n<p>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&rsquo;s return is the cleanup.<\/p>\n<pre><code class=\"language-jsx\">export function useEventListener(target, event, handler) {\n  const ref = useRef(handler);\n  ref.current = handler;\n\n  useEffect(() =&gt; {\n    const el = target?.current ?? target ?? window;\n    const listener = (e) =&gt; ref.current(e);\n    el.addEventListener(event, listener);\n    return () =&gt; el.removeEventListener(event, listener);\n  }, [target, event]);\n}\n<\/code><\/pre>\n<p>The bit people get wrong: capturing <code>handler<\/code> directly inside the effect. Then either you list <code>handler<\/code> in the dependency array and re-bind on every render, or you don&rsquo;t and the listener fires with a stale closure forever. The ref trick fixes both: stable listener, always-fresh handler.<\/p>\n<p>Use it for <code>keydown<\/code>, <code>resize<\/code>, <code>scroll<\/code>, <code>visibilitychange<\/code>. Don&rsquo;t use it for events that have a React equivalent. <code>onClick<\/code> is still <code>onClick<\/code>.<\/p>\n<h2 id=\"useelementsize-and-the-resizeobserver-gotcha\">useElementSize and the ResizeObserver gotcha<\/h2>\n<p>This one I use mostly in dashboards where some chart needs to know its container width to redraw.<\/p>\n<pre><code class=\"language-jsx\">export function useElementSize(ref) {\n  const [size, setSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() =&gt; {\n    const el = ref.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) =&gt; {\n      const { width, height } = entry.contentRect;\n      setSize({ width, height });\n    });\n    ro.observe(el);\n    return () =&gt; ro.disconnect();\n  }, [ref]);\n\n  return size;\n}\n<\/code><\/pre>\n<p>The gotcha I missed for an embarrassingly long time: <code>ResizeObserver<\/code> fires before paint, so calling <code>setState<\/code> inside it can trigger a &ldquo;ResizeObserver loop limit exceeded&rdquo; 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 <code>setState<\/code>. The cheap version is comparing rounded values.<\/p>\n<h2 id=\"what-i-stopped-reaching-for\">What I stopped reaching for<\/h2>\n<p>A short list of hooks I wrote, used, and quietly deleted.<\/p>\n<p><code>useToggle<\/code> was almost always shorter as <code>setOpen((o) =&gt; !o)<\/code> inline. <code>usePrevious<\/code> I used once for a comparison that should have been a <code>useEffect<\/code> dependency. <code>useMount<\/code> and <code>useUnmount<\/code> are <code>useEffect(fn, [])<\/code> with extra steps, and worse they hide the dependency array so readers can&rsquo;t tell the effect is intentional-empty. I had a <code>useLocalStorage<\/code> separate from <code>usePersistedState<\/code> for a while, same thing, two files, pick one. And every <code>useApi<\/code> wrapper around fetch eventually became the broken <code>useFetch<\/code> I described above.<\/p>\n<p>I don&rsquo;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 &ldquo;in case I need it later&rdquo; became dead code I had to maintain.<\/p>\n<h2 id=\"when-not-to-write-a-custom-hook\">When NOT to write a custom hook<\/h2>\n<p>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 <code>useEffect<\/code> and never read it twice in the same component, do that. The hook is only earned when the same chain of <code>useState<\/code> plus <code>useEffect<\/code> plus event listener shows up in two or three places and copying it the third time finally annoys me.<\/p>\n<p>The other anti-pattern: hooks that pretend to be components. If your &ldquo;hook&rdquo; calls four other hooks, returns 12 things, and is only ever used by one component, you&rsquo;ve written a component without the JSX. Refactor it into a real component with children, or into separate smaller hooks.<\/p>\n<p>I cover a lot of this kind of &ldquo;what I actually keep versus what I deleted&rdquo; thinking in my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">day-to-day work<\/a>. The through-line in most of it is that taste is mostly about what you stop doing, not what you start.<\/p>\n<h2 id=\"a-small-action-for-this-week\">A small action for this week<\/h2>\n<p>Go look at your <code>hooks\/<\/code> 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&rsquo;ll lose maybe four files and gain back the ability to read your own code six months from now without doing archaeology.<\/p>\n<p>That&rsquo;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.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>What I actually keep in the hooks folder in 2026 &#8211; usePersistedState, useDebouncedValue, useEventListener &#8211; and the half-dozen custom hooks I quietly deleted.<\/p>\n","protected":false},"author":2,"featured_media":311,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"What I actually keep in the hooks folder in 2026 - usePersistedState, useDebouncedValue, useEventListener - and the half-dozen custom hooks I quietly deleted.","rank_math_focus_keyword":"react custom hooks","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[354],"tags":[38,44,41,355],"class_list":["post-312","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-react","tag-frontend","tag-javascript","tag-react","tag-react-hooks-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/312","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=312"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/312\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/311"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=312"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=312"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=312"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}