{"id":553,"date":"2026-08-06T13:00:58","date_gmt":"2026-08-06T13:00:58","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-suspense-2026-the-loading-states-i-stopped-writing\/"},"modified":"2026-08-06T13:00:58","modified_gmt":"2026-08-06T13:00:58","slug":"react-suspense-2026-the-loading-states-i-stopped-writing","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-suspense-2026-the-loading-states-i-stopped-writing\/","title":{"rendered":"React Suspense in 2026: The Loading States I Stopped Writing"},"content":{"rendered":"<p>Confession: I avoided Suspense for data fetching for about three years because every explanation I read started with &ldquo;React throws a promise&rdquo; and my brain filed that under &ldquo;clever thing that will eventually hurt me.&rdquo;<\/p>\n<p>Then I counted the loading state code in one of our dashboards. Nine components, each with its own <code>loading<\/code>, <code>error<\/code>, and <code>data<\/code> triple, each with a slightly different spinner because three different people wrote them. Roughly 200 lines whose entire job was to say &ldquo;not yet.&rdquo;<\/p>\n<p>I deleted most of it in an afternoon. Here&rsquo;s what I got right, what I got wrong, and the part that still trips me up.<\/p>\n<h2 id=\"the-pattern-i-was-writing-before\">The pattern I was writing before<\/h2>\n<p>This is the shape almost every React codebase has somewhere:<\/p>\n<pre><code class=\"language-jsx\">function Profile({ userId }) {\n  const [user, setUser] = useState(null);\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState(null);\n\n  useEffect(() =&gt; {\n    let cancelled = false;\n    setLoading(true);\n    fetchUser(userId)\n      .then((u) =&gt; { if (!cancelled) setUser(u); })\n      .catch((e) =&gt; { if (!cancelled) setError(e); })\n      .finally(() =&gt; { if (!cancelled) setLoading(false); });\n    return () =&gt; { cancelled = true; };\n  }, [userId]);\n\n  if (loading) return &lt;Spinner \/&gt;;\n  if (error) return &lt;ErrorMessage error={error} \/&gt;;\n  return &lt;h1&gt;{user.name}&lt;\/h1&gt;;\n}\n<\/code><\/pre>\n<p>Twenty lines, and four of them exist purely to handle the case where the component unmounts mid request. I have shipped the version of this without the <code>cancelled<\/code> flag more than once and then spent an afternoon confused about a state update warning.<\/p>\n<h2 id=\"the-same-component-with-use-and-suspense\">The same component with use() and Suspense<\/h2>\n<p>React 19 shipped <code>use<\/code>, which reads a promise during render and hands the waiting off to the nearest Suspense boundary. The <a href=\"https:\/\/react.dev\/reference\/react\/use\" rel=\"nofollow noopener\" target=\"_blank\">reference docs for <code>use<\/code><\/a> are short and worth reading in full, partly because of one detail: despite the naming, <code>use<\/code> isn&rsquo;t a hook, so you can call it inside conditionals and loops.<\/p>\n<pre><code class=\"language-jsx\">function Profile({ userPromise }) {\n  const user = use(userPromise);\n  return &lt;h1&gt;{user.name}&lt;\/h1&gt;;\n}\n\nfunction Page({ userId }) {\n  const userPromise = useMemo(() =&gt; fetchUser(userId), [userId]);\n  return (\n    &lt;ErrorBoundary fallback={&lt;ErrorMessage \/&gt;}&gt;\n      &lt;Suspense fallback={&lt;Spinner \/&gt;}&gt;\n        &lt;Profile userPromise={userPromise} \/&gt;\n      &lt;\/Suspense&gt;\n    &lt;\/ErrorBoundary&gt;\n  );\n}\n<\/code><\/pre>\n<p>The component that renders the data now only knows about data. Loading moved to Suspense, failure moved to the error boundary, and the unmount race disappeared because there&rsquo;s no <code>setState<\/code> to lose.<\/p>\n<p>That split is the actual win. Not fewer lines, though there are fewer lines. The win is that each concern lives in exactly one place instead of being copy pasted into every leaf component by whoever wrote it that sprint.<\/p>\n<h2 id=\"the-caching-problem-i-walked-straight-into\">The caching problem I walked straight into<\/h2>\n<p>Here&rsquo;s where I lost an afternoon.<\/p>\n<p>My first version didn&rsquo;t have the <code>useMemo<\/code>. It called <code>fetchUser(userId)<\/code> directly in the parent&rsquo;s render body. Which meant every parent re-render created a fresh promise, <code>use<\/code> saw a new promise, Suspense fell back to the spinner, and the network tab filled up with duplicate requests. The UI flickered on every unrelated state change and I blamed React for a while before reading my own code.<\/p>\n<p>The rule: the promise has to be stable across renders, or you&rsquo;re building an infinite fetch loop with extra steps.<\/p>\n<p><code>useMemo<\/code> is the duct tape version and it&rsquo;s fine for simple cases, but React explicitly doesn&rsquo;t guarantee memo persistence. In real applications you want a cache that lives outside the component. That&rsquo;s most of what React Query and SWR are actually selling, and it&rsquo;s why &ldquo;Suspense replaces your data library&rdquo; was never true. Suspense replaces your loading state. It doesn&rsquo;t replace your cache, your deduplication, your revalidation, or your retry policy.<\/p>\n<p>The <a href=\"https:\/\/react.dev\/reference\/react\/Suspense\" rel=\"nofollow noopener\" target=\"_blank\">Suspense reference<\/a> is direct about the boundaries of what it handles, and the <a href=\"https:\/\/react.dev\/blog\/2024\/12\/05\/react-19\" rel=\"nofollow noopener\" target=\"_blank\">React 19 release notes<\/a> cover how <code>use<\/code> interacts with the rest of the release.<\/p>\n<h2 id=\"suspense-doesnt-catch-errors-and-i-keep-forgetting\">Suspense doesn&rsquo;t catch errors, and I keep forgetting<\/h2>\n<p>A rejected promise passed to <code>use<\/code> doesn&rsquo;t get handled by Suspense. It propagates up to the nearest error boundary. If you don&rsquo;t have one, your user sees a blank white page.<\/p>\n<p>So <code>use<\/code> without a wrapping error boundary is strictly worse than the <code>useEffect<\/code> version, because at least the old one had an <code>error<\/code> branch. I made this mistake in a staging deploy and only caught it because someone&rsquo;s auth token expired at the right moment.<\/p>\n<p>The pairing is not optional:<\/p>\n<pre><code class=\"language-jsx\">&lt;ErrorBoundary fallback={&lt;Retry \/&gt;}&gt;\n  &lt;Suspense fallback={&lt;Skeleton \/&gt;}&gt;\n    &lt;Dashboard \/&gt;\n  &lt;\/Suspense&gt;\n&lt;\/ErrorBoundary&gt;\n<\/code><\/pre>\n<p>I went deeper on the boundary side of this in <a href=\"https:\/\/abrarqasim.com\/blog\/react-error-boundaries-2026-how-i-stopped-shipping-white-screens\" rel=\"noopener\">how I stopped shipping white screens<\/a>, including where to put boundaries so one failed widget doesn&rsquo;t take down a whole page.<\/p>\n<h2 id=\"the-other-suspense-the-one-you-might-already-be-using\">The other Suspense, the one you might already be using<\/h2>\n<p>Worth separating two things that share a name, because I conflated them for years and it made the docs harder to read than they needed to be.<\/p>\n<p>Suspense shipped originally for code splitting, paired with <code>lazy<\/code>. That version has been stable and boring since React 16.6:<\/p>\n<pre><code class=\"language-jsx\">const Settings = lazy(() =&gt; import('.\/Settings'));\n\n&lt;Suspense fallback={&lt;Skeleton \/&gt;}&gt;\n  &lt;Settings \/&gt;\n&lt;\/Suspense&gt;\n<\/code><\/pre>\n<p>Same boundary, same fallback, different thing being waited on. Here it&rsquo;s a JavaScript chunk over the network rather than your data. The <a href=\"https:\/\/react.dev\/reference\/react\/lazy\" rel=\"nofollow noopener\" target=\"_blank\"><code>lazy<\/code> reference<\/a> covers it, and if you&rsquo;re on a framework you&rsquo;re probably already getting this without writing it yourself.<\/p>\n<p>That&rsquo;s the useful mental model: a Suspense boundary is a declaration that says &ldquo;something below me isn&rsquo;t ready, show this instead.&rdquo; It doesn&rsquo;t care whether the missing thing is a bundle or a database row. Once that clicked, the data fetching version stopped feeling like a new API and started feeling like the same API pointed at a different problem.<\/p>\n<p>It also explains why boundaries stack sensibly. A lazy loaded route inside an outer boundary, with its own inner boundary around a slow widget, does the reasonable thing: the outer fallback covers the chunk download, the inner one covers the data, and the user sees the shell appear before the contents fill in.<\/p>\n<h2 id=\"boundary-placement-is-the-part-that-takes-taste\">Boundary placement is the part that takes taste<\/h2>\n<p>Once the mechanics work, the remaining question is where the boundaries go, and this is genuinely a judgment call rather than a rule.<\/p>\n<p>One boundary at the top of the page is the easiest thing to write and usually the worst experience. The whole page waits on the slowest request in it. If your user avatar fetch is slow, your fast chart sits there being invisible for no reason.<\/p>\n<p>A boundary around every single component goes the other way. You get a page of independently popping skeletons that reflow four times while it settles, which reads as broken even though every individual piece is doing something reasonable.<\/p>\n<p>What I&rsquo;ve landed on: one boundary per region that a user would describe as a thing. The sidebar. The chart. The comment list. If a person would name it out loud, it probably deserves its own fallback. If they wouldn&rsquo;t, it can share one with its neighbour.<\/p>\n<p>And make the fallback the right shape. A skeleton that matches the final layout stops the reflow entirely. A centered spinner in a container that later fills with a table means the page jumps, and layout shift is the kind of thing that feels cheap without anyone being able to say why.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Find the component in your codebase with the most <code>useState(false)<\/code> calls for loading. There&rsquo;s always one. It&rsquo;s usually the dashboard or the settings page.<\/p>\n<p>Convert exactly that one. Wrap it in an error boundary, then a Suspense boundary with a skeleton shaped like the real content, hoist the fetch to a promise created outside render, and delete the loading branches. Keep it on a branch and click through it before you commit, particularly the case where the request fails, because that&rsquo;s the path that silently regresses.<\/p>\n<p>If you&rsquo;re already on React Query or SWR, you don&rsquo;t need <code>use<\/code> at all. Turn on the suspense mode your library already has and you get the same component level cleanup with the cache you&rsquo;re currently relying on. That&rsquo;s the boring answer and it&rsquo;s the right one for most teams.<\/p>\n<p>I keep working notes on migrations like this, including the ones I abandoned halfway, over on <a href=\"https:\/\/abrarqasim.com\/about\" rel=\"noopener\">my site<\/a>. The abandoned ones are usually more instructive.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I deleted about 200 lines of loading state using React 19 Suspense and use(). What worked, the caching bug I hit, and where to put your boundaries.<\/p>\n","protected":false},"author":2,"featured_media":552,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I deleted about 200 lines of loading state using React 19 Suspense and use(). What worked, the caching bug I hit, and where to put your boundaries.","rank_math_focus_keyword":"react suspense","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[45],"tags":[301,69,41,43,414],"class_list":["post-553","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-programming","tag-data-fetching","tag-hooks","tag-react","tag-react-19","tag-suspense"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/553","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=553"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/553\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/552"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=553"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=553"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=553"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}