{"id":563,"date":"2026-08-09T05:02:11","date_gmt":"2026-08-09T05:02:11","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-hooks-best-practices-2026-the-rules-i-actually-keep\/"},"modified":"2026-08-09T05:02:11","modified_gmt":"2026-08-09T05:02:11","slug":"react-hooks-best-practices-2026-the-rules-i-actually-keep","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-hooks-best-practices-2026-the-rules-i-actually-keep\/","title":{"rendered":"React Hooks Best Practices in 2026: The Rules I Actually Keep"},"content":{"rendered":"<p>Last week I was cleaning out an old repo and found a file called HOOKS_RULES.md that I wrote for a client team in 2023. Fourteen rules. I read it over coffee and winced, because more than half of it is now wrong or pointless. Wrap every callback in useCallback. Memoize before you profile. Fetch in useEffect with a cleanup flag. All of it written with total confidence by a slightly younger me.<\/p>\n<p>React 19 and the React Compiler deleted a whole genre of hooks advice, including some I was still handing out last year. So this post is the 2026 rewrite of that file: what I dropped, what I kept, and the code that changed. If your code review comments still enforce the 2023 rules, some of them are actively making your codebase worse. Mine were.<\/p>\n<h2 id=\"the-old-rules-that-didnt-survive\">The old rules that didn&rsquo;t survive<\/h2>\n<p>Three casualties stand out when I reread that file.<\/p>\n<p>The first was &ldquo;memoize aggressively.&rdquo; I had a rule that any function passed as a prop gets useCallback, no exceptions. The second was &ldquo;fetch in useEffect,&rdquo; complete with the ignore-flag dance to avoid race conditions. The third was &ldquo;extract a custom hook once a component passes 100 lines,&rdquo; which produced hooks like useProfilePageLogic that were impossible to test and were reused exactly nowhere.<\/p>\n<p>None of these were stupid at the time. They were workarounds for real problems. The interesting part is that all three problems got solved upstream, and the workarounds are now dead weight.<\/p>\n<h2 id=\"stop-memoizing-by-hand\">Stop memoizing by hand<\/h2>\n<p>This is the big one. The <a href=\"https:\/\/react.dev\/learn\/react-compiler\" rel=\"nofollow noopener\" target=\"_blank\">React Compiler<\/a> hit stable in 2025, and it memoizes components and hooks automatically at build time. Here&rsquo;s what a search component looked like under my 2023 rules:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 18, the old way\nfunction SearchResults({ query, onSelect }) {\n  const results = useMemo(() =&gt; filterResults(query), [query]);\n  const handleSelect = useCallback(\n    (id) =&gt; onSelect(id),\n    [onSelect]\n  );\n  return &lt;ResultList results={results} onSelect={handleSelect} \/&gt;;\n}\n<\/code><\/pre>\n<p>And here&rsquo;s the same component today, with the compiler enabled:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 19 + compiler\nfunction SearchResults({ query, onSelect }) {\n  const results = filterResults(query);\n  const handleSelect = (id) =&gt; onSelect(id);\n  return &lt;ResultList results={results} onSelect={handleSelect} \/&gt;;\n}\n<\/code><\/pre>\n<p>The second version isn&rsquo;t lazier. It&rsquo;s better. The compiler applies memoization more consistently than I ever did, and it can&rsquo;t forget a dependency. I wrote up the full migration, including the two components where the compiler bailed out and I kept manual memoization, in <a href=\"https:\/\/abrarqasim.com\/blog\/react-compiler-2026-what-happened-when-i-stopped-memoizing\" rel=\"noopener\">what happened when I stopped memoizing<\/a>.<\/p>\n<p>One caveat I learned the slow way: the compiler only optimizes code that follows the Rules of React. If your component mutates props or reads refs during render, the compiler skips it silently and you get the old performance. Keep eslint-plugin-react-hooks on error, not warn. The lint rules are how you find out the compiler gave up on you.<\/p>\n<h2 id=\"useeffect-is-not-a-data-fetching-hook\">useEffect is not a data-fetching hook<\/h2>\n<p>My 2023 file had a whole section on fetching in effects, with this pattern copied into probably a dozen client projects:<\/p>\n<pre><code class=\"language-jsx\">\/\/ The 2023 ritual\nfunction Profile({ userId }) {\n  const [user, setUser] = useState(null);\n\n  useEffect(() =&gt; {\n    let ignore = false;\n    fetchUser(userId).then((data) =&gt; {\n      if (!ignore) setUser(data);\n    });\n    return () =&gt; {\n      ignore = true;\n    };\n  }, [userId]);\n\n  if (!user) return &lt;Spinner \/&gt;;\n  return &lt;h1&gt;{user.name}&lt;\/h1&gt;;\n}\n<\/code><\/pre>\n<p>Every line of that is boilerplate protecting against a race condition that the platform now handles. With React 19&rsquo;s <code>use<\/code> and a Suspense boundary, the component becomes:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 19\nfunction Profile({ userId }) {\n  const user = use(getUser(userId));\n  return &lt;h1&gt;{user.name}&lt;\/h1&gt;;\n}\n<\/code><\/pre>\n<p>The honest caveat: <code>getUser<\/code> needs to return a cached promise, because creating a fresh promise on every render will loop. In practice I let React Query or a framework loader own that cache and read it with <code>use<\/code>. The React docs are direct about this in <a href=\"https:\/\/react.dev\/learn\/you-might-not-need-an-effect\" rel=\"nofollow noopener\" target=\"_blank\">You Might Not Need an Effect<\/a>, which I&rsquo;d call required reading, except I ignored it for a year, so I&rsquo;ll just say it&rsquo;s the page I wish I&rsquo;d read sooner. I covered where the loading states went in <a href=\"https:\/\/abrarqasim.com\/blog\/react-suspense-2026-the-loading-states-i-stopped-writing\" rel=\"noopener\">the loading states I stopped writing<\/a>.<\/p>\n<p>Effects still have a job. That job is synchronizing with systems outside React: a WebSocket, a map library, an analytics call. If the effect only moves React state around, it probably shouldn&rsquo;t exist.<\/p>\n<h2 id=\"a-custom-hook-has-to-earn-its-name\">A custom hook has to earn its name<\/h2>\n<p>The 2023 rule extracted hooks by line count. The 2026 rule extracts hooks by question: would I want this exact logic in a second component? If yes, extract. If I&rsquo;m just hiding length, I leave it inline, or split the component instead.<\/p>\n<p>The custom hooks that survived in my <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">client work<\/a> all wrap one external concern. This one is the oldest:<\/p>\n<pre><code class=\"language-jsx\">function useOnlineStatus() {\n  const [isOnline, setIsOnline] = useState(true);\n\n  useEffect(() =&gt; {\n    const on = () =&gt; setIsOnline(true);\n    const off = () =&gt; setIsOnline(false);\n    window.addEventListener(&quot;online&quot;, on);\n    window.addEventListener(&quot;offline&quot;, off);\n    return () =&gt; {\n      window.removeEventListener(&quot;online&quot;, on);\n      window.removeEventListener(&quot;offline&quot;, off);\n    };\n  }, []);\n\n  return isOnline;\n}\n<\/code><\/pre>\n<p>Small, testable, reused in three projects. Compare that with useProfilePageLogic, which took eleven arguments and returned an object with fourteen keys. The <a href=\"https:\/\/react.dev\/learn\/reusing-logic-with-custom-hooks\" rel=\"nofollow noopener\" target=\"_blank\">React docs on custom hooks<\/a> make the same point with more patience than I have: share logic, not lifecycle.<\/p>\n<p>Naming still matters, and the compiler makes it mechanical. Anything called <code>use*<\/code> gets treated as a hook by the linter and the compiler, so a helper that isn&rsquo;t a hook shouldn&rsquo;t wear the prefix.<\/p>\n<h2 id=\"the-rules-i-still-enforce\">The rules I still enforce<\/h2>\n<p>Dependency arrays are still real. The compiler memoizes, but it doesn&rsquo;t fix a lying dependency array, and exhaustive-deps still catches genuine bugs. I keep it on error.<\/p>\n<p>Hooks stay at the top level. No conditions, no loops, no early returns before the last hook. React 19 didn&rsquo;t relax this, and the compiler depends on it.<\/p>\n<p>Derived state gets computed during render, not mirrored into useState. If you can calculate it from props, calculate it. Copying props into state was the number one bug source in every codebase I audited last year, and no compiler saves you from stale copies.<\/p>\n<p>Server state lives in a server-state library or a framework loader, not in a pile of useState. This one predates React 19 and it will outlive whatever comes next.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>Run <code>npx react-compiler-healthcheck<\/code> on your main app. It takes about a minute and tells you what percentage of your components the compiler can optimize. If the number is high, enable the compiler on one route, delete the useMemo and useCallback calls in that route, and compare a React DevTools profile before and after. That&rsquo;s a one-afternoon experiment, and it&rsquo;s how I found out my fourteen-rule file was mostly a museum piece.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>React 19 and the compiler quietly killed half the hooks advice I used to give. Here&#8217;s what I dropped, what I kept, and the code that changed.<\/p>\n","protected":false},"author":2,"featured_media":562,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"React 19 and the compiler quietly killed half the hooks advice I used to give. Here's what I dropped, what I kept, and the code that changed.","rank_math_focus_keyword":"react hooks best practices","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[45,354],"tags":[624,41,43,194,195],"class_list":["post-563","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-programming","category-react","tag-custom-hooks","tag-react","tag-react-19","tag-react-hooks","tag-useeffect"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/563","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=563"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/563\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/562"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=563"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=563"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=563"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}