{"id":671,"date":"2026-09-11T13:03:27","date_gmt":"2026-09-11T13:03:27","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-19-3-view-transitions-stable-the-timeout-hack-i-deleted\/"},"modified":"2026-09-11T13:03:27","modified_gmt":"2026-09-11T13:03:27","slug":"react-19-3-view-transitions-stable-the-timeout-hack-i-deleted","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-19-3-view-transitions-stable-the-timeout-hack-i-deleted\/","title":{"rendered":"React 19.3: View Transitions Are Stable, the Timeout Hack Is Gone"},"content":{"rendered":"<p>Short version for the impatient: React 19.3 shipped on September 9 and the two things I actually care about, View Transitions and Fragment Refs, are no longer behind the experimental flag. If you want to know why I deleted about 200 lines of animation glue from a client dashboard this week, read on.<\/p>\n<p>I have a confession. I&rsquo;ve been shipping <code>&lt;ViewTransition&gt;<\/code> from the experimental channel in one project since early this year, which is the kind of thing I tell clients not to do. I justified it because the alternative was Framer Motion for a single page transition, and that felt like buying a truck to move a chair. So when the <a href=\"https:\/\/react.dev\/blog\/2026\/09\/09\/react-19-3\" rel=\"nofollow noopener\" target=\"_blank\">React 19.3 release post<\/a> landed with both APIs marked stable, my first reaction wasn&rsquo;t excitement. It was relief that I could stop pinning a canary build and pretending that was fine.<\/p>\n<p>There&rsquo;s more in the release than animations, though. A <code>browser()<\/code> API that finally gives &ldquo;don&rsquo;t render this on the server&rdquo; a real name, Trusted Types support, and Server Components that can render a Context directly. I&rsquo;ll go through what changed, show the before and after for each, and be honest about the parts that still feel rough.<\/p>\n<h2 id=\"view-transitions-the-enterexit-animation-i-used-to-fake\">View Transitions: the enter\/exit animation I used to fake<\/h2>\n<p>If you&rsquo;ve done a fade-in on a conditionally rendered element in React 18, you know the dance. You either reach for a library, or you hand-roll something with a mounted flag, a CSS class, and a <code>setTimeout<\/code> that keeps the element in the DOM long enough for the exit animation to finish. Here&rsquo;s roughly what mine looked like:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 18: keep the node alive long enough to animate out\nfunction Panel({ open, children }) {\n  const [render, setRender] = useState(open);\n  useEffect(() =&gt; {\n    if (open) setRender(true);\n    else {\n      const t = setTimeout(() =&gt; setRender(false), 200);\n      return () =&gt; clearTimeout(t);\n    }\n  }, [open]);\n  if (!render) return null;\n  return &lt;div className={open ? 'panel fade-in' : 'panel fade-out'}&gt;{children}&lt;\/div&gt;;\n}\n<\/code><\/pre>\n<p>That&rsquo;s not terrible. It&rsquo;s also a bug factory the moment <code>open<\/code> flips twice within 200ms, and every one of these I wrote had a slightly different timeout value because I never bothered to centralise it.<\/p>\n<p>In 19.3 the same thing is a wrapper and a transition:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 19.3\nimport { ViewTransition, startTransition, useState } from 'react';\n\nfunction Panel({ open, children }) {\n  return open &amp;&amp; (\n    &lt;ViewTransition&gt;\n      &lt;div className=&quot;panel&quot;&gt;{children}&lt;\/div&gt;\n    &lt;\/ViewTransition&gt;\n  );\n}\n\n\/\/ wherever you toggle it\nstartTransition(() =&gt; setOpen(o =&gt; !o));\n<\/code><\/pre>\n<p>The important detail, and the one I got wrong on day one, is that the update has to be a Transition. A plain <code>setOpen<\/code> outside <code>startTransition<\/code> is treated as urgent and won&rsquo;t animate. Updates from <code>useDeferredValue<\/code> and Suspense reveals also count. React then figures out which animation applies: enter, exit, update, or share (a named <code>&lt;ViewTransition&gt;<\/code> removed in one place and added in another). Under the hood it&rsquo;s the browser&rsquo;s <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/View_Transition_API\" rel=\"nofollow noopener\" target=\"_blank\">View Transition API<\/a>, so you get the same snapshot-and-crossfade behaviour that vanilla JS gets, just driven by React&rsquo;s commit phase instead of your own DOM diffing.<\/p>\n<p>Default animation is a crossfade. You can pass a class per animation type and write the keyframes in CSS, or hook the <code>onEnter<\/code> \/ <code>onExit<\/code> event props and use the Web Animations API directly. I&rsquo;ve stuck with CSS classes. It&rsquo;s less code and my designer can edit them without touching JSX.<\/p>\n<p>One limitation worth saying out loud: this is DOM only. The React team says React Native support is in progress, but if you share components across web and native today, you&rsquo;ll need a wrapper.<\/p>\n<h2 id=\"addtransitiontype-and-the-carousel-problem\">addTransitionType and the carousel problem<\/h2>\n<p>Here&rsquo;s a thing I didn&rsquo;t know I needed. Two buttons in a carousel both set <code>currentSlide<\/code>, but &ldquo;next&rdquo; should slide right-to-left and &ldquo;previous&rdquo; the other way. Same state update, different animation. Before 19.3 I stored a <code>direction<\/code> in state alongside the slide index, which is one of those pieces of state that exists only for presentation and always felt wrong.<\/p>\n<p>Now you tag the transition at the call site:<\/p>\n<pre><code class=\"language-jsx\">function next() {\n  startTransition(() =&gt; {\n    addTransitionType('next');\n    setSlide(s =&gt; s + 1);\n  });\n}\n\n&lt;ViewTransition\n  enter={{ next: 'from-right', previous: 'from-left' }}\n  exit={{ next: 'to-left', previous: 'to-right' }}\n&gt;\n  &lt;Slide \/&gt;\n&lt;\/ViewTransition&gt;\n<\/code><\/pre>\n<p>React also forwards each type to the browser as a view transition type, so you can scope CSS with <code>:active-view-transition-type(next)<\/code> if you&rsquo;d rather keep the mapping in the stylesheet. I haven&rsquo;t decided which I prefer. The prop version is more discoverable; the CSS version keeps the component ignorant of animation, which is arguably where that knowledge belongs.<\/p>\n<h2 id=\"the-suspense-integration-is-the-part-that-bit-me\">The Suspense integration is the part that bit me<\/h2>\n<p>Wrap a Suspense boundary in <code>&lt;ViewTransition&gt;<\/code> and React animates from the fallback to the loaded content. Great in theory. In practice my first attempt made the whole app feel slower, because everything animated, including content that was already cached and should have popped in instantly. Even the fallback faded in, which is exactly what you don&rsquo;t want from a loading state.<\/p>\n<p>The fix is in the docs but easy to skim past:<\/p>\n<pre><code class=\"language-jsx\">&lt;ViewTransition update=&quot;auto&quot; default=&quot;none&quot;&gt;\n  &lt;Suspense fallback={&lt;Skeleton \/&gt;}&gt;\n    &lt;Profile \/&gt;\n  &lt;\/Suspense&gt;\n&lt;\/ViewTransition&gt;\n<\/code><\/pre>\n<p><code>default=\"none\"<\/code> turns off enter and exit; <code>update=\"auto\"<\/code> keeps only the fallback-to-content morph. The principle the React team lays out is worth memorising: fallbacks appear immediately, the swap to real content animates, and anything that didn&rsquo;t suspend appears immediately too. I&rsquo;d add my own rule. If you can&rsquo;t explain why a specific animation helps the user understand what changed, it&rsquo;s decoration, and decoration on a loading path is a tax.<\/p>\n<p>There&rsquo;s a second, quieter feature here. Images and fonts inside a <code>&lt;ViewTransition&gt;&lt;Suspense&gt;<\/code> pair now trigger Suspense while they load. So a profile card that needs data, an avatar, and a webfont can wait for all three and reveal once, instead of the text landing first and the image flickering in half a second later. I&rsquo;ve wanted a coordinated reveal like this since roughly 2019 and always ended up with a hand-rolled <code>Promise.all<\/code> and an <code>onLoad<\/code> handler.<\/p>\n<h2 id=\"fragment-refs-attaching-behaviour-without-a-wrapper-div\">Fragment Refs: attaching behaviour without a wrapper div<\/h2>\n<p>This one solves a problem I&rsquo;ve hit in every design system I&rsquo;ve worked on. A component renders a list of siblings, no single parent, and you need to observe visibility or move focus across the group. Or the component comes from a library and doesn&rsquo;t forward its ref. The usual answer is a wrapper <code>&lt;div&gt;<\/code>, which then breaks the flex layout you spent an hour on.<\/p>\n<p>In 19.3 you can pass a ref to <code>&lt;Fragment&gt;<\/code> and get a <code>FragmentInstance<\/code> back:<\/p>\n<pre><code class=\"language-jsx\">function Headings({ posts }) {\n  const ref = useRef(null);\n  useEffect(() =&gt; {\n    ref.current.focus(); \/\/ moves focus to the first focusable child, depth first\n  }, []);\n  return (\n    &lt;Fragment ref={ref}&gt;\n      {posts.map(p =&gt; &lt;Heading key={p.id}&gt;{p.title}&lt;\/Heading&gt;)}\n    &lt;\/Fragment&gt;\n  );\n}\n<\/code><\/pre>\n<p>The instance gives you a curated set of DOM operations: <code>addEventListener<\/code> and friends for first-level children, <code>focus<\/code> \/ <code>focusLast<\/code> \/ <code>blur<\/code>, <code>observeUsing<\/code> for an IntersectionObserver or ResizeObserver, and measurement helpers like <code>getClientRects<\/code> and <code>scrollIntoView<\/code>. It is deliberately not a full DOM node. You can&rsquo;t set <code>innerHTML<\/code> on it, and I think that restraint is correct. The <a href=\"https:\/\/react.dev\/reference\/react\/Fragment\" rel=\"nofollow noopener\" target=\"_blank\"><code>&lt;Fragment&gt;<\/code> reference<\/a> has the full list.<\/p>\n<p>I rebuilt an <code>InView<\/code> component with it in about twenty minutes. Before, it wrapped children in a <code>&lt;div style=\"display: contents\"&gt;<\/code>, which works until you need <code>getBoundingClientRect<\/code> and discover that <code>display: contents<\/code> elements have no box. Now it observes the children directly. Same API for consumers, one fewer lie in the DOM.<\/p>\n<h2 id=\"browser-the-mounted-flag-finally-has-a-name\">browser(): the mounted flag finally has a name<\/h2>\n<p>Every SSR codebase I&rsquo;ve touched has some version of this:<\/p>\n<pre><code class=\"language-jsx\">\/\/ React 18\nfunction LocalTime() {\n  const [mounted, setMounted] = useState(false);\n  useEffect(() =&gt; setMounted(true), []);\n  if (!mounted) return null;\n  return &lt;p&gt;{new Intl.DateTimeFormat().resolvedOptions().timeZone}&lt;\/p&gt;;\n}\n<\/code><\/pre>\n<p>Or the uglier cousin, <code>typeof window !== 'undefined'<\/code>, which causes hydration mismatches the moment you forget that the server and first client render must agree. React 19.3 replaces both with <code>use(browser())<\/code> from <code>react-dom<\/code>:<\/p>\n<pre><code class=\"language-jsx\">import { use } from 'react';\nimport { browser } from 'react-dom';\n\nfunction LocalTime() {\n  use(browser());\n  return &lt;p&gt;{new Intl.DateTimeFormat().resolvedOptions().timeZone}&lt;\/p&gt;;\n}\n<\/code><\/pre>\n<p>On the server it suspends, so the nearest Suspense fallback goes into the HTML. On the client it doesn&rsquo;t, and the component renders normally after hydration. Because <code>use<\/code> can sit behind a condition, you can opt out only when you lack a default value, or only when a data hook has no <code>initialData<\/code>. That last pattern, wrapping <code>useQuery<\/code> so it renders on the server when the loader passed data and defers otherwise, is the cleanest solution to the &ldquo;should this fetch on the server or not&rdquo; question I&rsquo;ve seen. I covered a related idea when I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/useoptimistic-react-19-the-spinner-i-finally-deleted\" rel=\"noopener\"><code>useOptimistic<\/code> and the spinner I finally deleted<\/a>; this is the same spirit of replacing ad hoc state with something React understands.<\/p>\n<h2 id=\"the-smaller-changes-id-actually-check-before-upgrading\">The smaller changes I&rsquo;d actually check before upgrading<\/h2>\n<p>Trusted Types: if your CSP enforces <code>require-trusted-types-for 'script'<\/code>, React previously coerced everything to a string before handing it to the DOM, which turned your <code>TrustedHTML<\/code> objects back into plain strings the browser rejected. 19.3 passes them through untouched. If you&rsquo;ve been holding off on that CSP directive because React fought it, that blocker is gone.<\/p>\n<p>Server Components can render a Context imported from a <code>'use client'<\/code> module directly, without a <code>Provider<\/code> wrapper component whose only job was to forward a prop. Small, but I have maybe eight of those wrappers in one app and they all get deleted.<\/p>\n<p>From the changelog, the one I&rsquo;d flag: Transitions now render independently instead of being entangled, so a slow Transition no longer blocks unrelated ones. That&rsquo;s a behaviour change, and if you had code accidentally relying on batching across transitions, test it. There&rsquo;s also a fix for <code>useDeferredValue<\/code> getting stuck on a stale value, which explains a bug I&rsquo;d blamed on my own code for a month.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>Upgrade a single leaf route, not the whole app. Find one place where you hand-rolled an exit animation with a timeout, replace it with <code>&lt;ViewTransition&gt;<\/code> inside <code>startTransition<\/code>, and check that it still works when the user double-clicks. Then grep for <code>setMounted(true)<\/code> and try <code>use(browser())<\/code> on one of them. If either feels worse, you&rsquo;ve lost an hour. If they feel better, you&rsquo;ve probably got a dozen more to convert, and that&rsquo;s a job I&rsquo;ve done for a few clients already; details of that kind of work are on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio<\/a>.<\/p>\n<p>I&rsquo;m still not sure about one thing: whether View Transitions will age well once every app crossfades everything. The API makes animation cheap, and cheap things get overused. Use it where motion explains a change. Leave the rest alone.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>React 19.3 makes View Transitions and Fragment Refs stable and adds use(browser()). Before-and-after code for each, plus the Suspense mistake I made first.<\/p>\n","protected":false},"author":2,"featured_media":670,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"React 19.3 makes View Transitions and Fragment Refs stable and adds use(browser()). Before-and-after code for each, plus the Suspense mistake I made first.","rank_math_focus_keyword":"react 19 stable release","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[45,354],"tags":[742,44,41,359,389,741],"class_list":["post-671","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-programming","category-react","tag-fragment-refs","tag-javascript","tag-react","tag-react-19-2","tag-server-components-2","tag-view-transitions"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/671","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=671"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/671\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/670"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=671"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=671"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=671"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}