Skip to content

React 19.3: View Transitions Are Stable, the Timeout Hack Is Gone

React 19.3: View Transitions Are Stable, the Timeout Hack Is Gone

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.

I have a confession. I’ve been shipping <ViewTransition> 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 React 19.3 release post landed with both APIs marked stable, my first reaction wasn’t excitement. It was relief that I could stop pinning a canary build and pretending that was fine.

There’s more in the release than animations, though. A browser() API that finally gives “don’t render this on the server” a real name, Trusted Types support, and Server Components that can render a Context directly. I’ll go through what changed, show the before and after for each, and be honest about the parts that still feel rough.

View Transitions: the enter/exit animation I used to fake

If you’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 setTimeout that keeps the element in the DOM long enough for the exit animation to finish. Here’s roughly what mine looked like:

// React 18: keep the node alive long enough to animate out
function Panel({ open, children }) {
  const [render, setRender] = useState(open);
  useEffect(() => {
    if (open) setRender(true);
    else {
      const t = setTimeout(() => setRender(false), 200);
      return () => clearTimeout(t);
    }
  }, [open]);
  if (!render) return null;
  return <div className={open ? 'panel fade-in' : 'panel fade-out'}>{children}</div>;
}

That’s not terrible. It’s also a bug factory the moment open flips twice within 200ms, and every one of these I wrote had a slightly different timeout value because I never bothered to centralise it.

In 19.3 the same thing is a wrapper and a transition:

// React 19.3
import { ViewTransition, startTransition, useState } from 'react';

function Panel({ open, children }) {
  return open && (
    <ViewTransition>
      <div className="panel">{children}</div>
    </ViewTransition>
  );
}

// wherever you toggle it
startTransition(() => setOpen(o => !o));

The important detail, and the one I got wrong on day one, is that the update has to be a Transition. A plain setOpen outside startTransition is treated as urgent and won’t animate. Updates from useDeferredValue and Suspense reveals also count. React then figures out which animation applies: enter, exit, update, or share (a named <ViewTransition> removed in one place and added in another). Under the hood it’s the browser’s View Transition API, so you get the same snapshot-and-crossfade behaviour that vanilla JS gets, just driven by React’s commit phase instead of your own DOM diffing.

Default animation is a crossfade. You can pass a class per animation type and write the keyframes in CSS, or hook the onEnter / onExit event props and use the Web Animations API directly. I’ve stuck with CSS classes. It’s less code and my designer can edit them without touching JSX.

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’ll need a wrapper.

Here’s a thing I didn’t know I needed. Two buttons in a carousel both set currentSlide, but “next” should slide right-to-left and “previous” the other way. Same state update, different animation. Before 19.3 I stored a direction in state alongside the slide index, which is one of those pieces of state that exists only for presentation and always felt wrong.

Now you tag the transition at the call site:

function next() {
  startTransition(() => {
    addTransitionType('next');
    setSlide(s => s + 1);
  });
}

<ViewTransition
  enter={{ next: 'from-right', previous: 'from-left' }}
  exit={{ next: 'to-left', previous: 'to-right' }}
>
  <Slide />
</ViewTransition>

React also forwards each type to the browser as a view transition type, so you can scope CSS with :active-view-transition-type(next) if you’d rather keep the mapping in the stylesheet. I haven’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.

The Suspense integration is the part that bit me

Wrap a Suspense boundary in <ViewTransition> 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’t want from a loading state.

The fix is in the docs but easy to skim past:

<ViewTransition update="auto" default="none">
  <Suspense fallback={<Skeleton />}>
    <Profile />
  </Suspense>
</ViewTransition>

default="none" turns off enter and exit; update="auto" 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’t suspend appears immediately too. I’d add my own rule. If you can’t explain why a specific animation helps the user understand what changed, it’s decoration, and decoration on a loading path is a tax.

There’s a second, quieter feature here. Images and fonts inside a <ViewTransition><Suspense> 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’ve wanted a coordinated reveal like this since roughly 2019 and always ended up with a hand-rolled Promise.all and an onLoad handler.

Fragment Refs: attaching behaviour without a wrapper div

This one solves a problem I’ve hit in every design system I’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’t forward its ref. The usual answer is a wrapper <div>, which then breaks the flex layout you spent an hour on.

In 19.3 you can pass a ref to <Fragment> and get a FragmentInstance back:

function Headings({ posts }) {
  const ref = useRef(null);
  useEffect(() => {
    ref.current.focus(); // moves focus to the first focusable child, depth first
  }, []);
  return (
    <Fragment ref={ref}>
      {posts.map(p => <Heading key={p.id}>{p.title}</Heading>)}
    </Fragment>
  );
}

The instance gives you a curated set of DOM operations: addEventListener and friends for first-level children, focus / focusLast / blur, observeUsing for an IntersectionObserver or ResizeObserver, and measurement helpers like getClientRects and scrollIntoView. It is deliberately not a full DOM node. You can’t set innerHTML on it, and I think that restraint is correct. The <Fragment> reference has the full list.

I rebuilt an InView component with it in about twenty minutes. Before, it wrapped children in a <div style="display: contents">, which works until you need getBoundingClientRect and discover that display: contents elements have no box. Now it observes the children directly. Same API for consumers, one fewer lie in the DOM.

browser(): the mounted flag finally has a name

Every SSR codebase I’ve touched has some version of this:

// React 18
function LocalTime() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;
  return <p>{new Intl.DateTimeFormat().resolvedOptions().timeZone}</p>;
}

Or the uglier cousin, typeof window !== 'undefined', which causes hydration mismatches the moment you forget that the server and first client render must agree. React 19.3 replaces both with use(browser()) from react-dom:

import { use } from 'react';
import { browser } from 'react-dom';

function LocalTime() {
  use(browser());
  return <p>{new Intl.DateTimeFormat().resolvedOptions().timeZone}</p>;
}

On the server it suspends, so the nearest Suspense fallback goes into the HTML. On the client it doesn’t, and the component renders normally after hydration. Because use can sit behind a condition, you can opt out only when you lack a default value, or only when a data hook has no initialData. That last pattern, wrapping useQuery so it renders on the server when the loader passed data and defers otherwise, is the cleanest solution to the “should this fetch on the server or not” question I’ve seen. I covered a related idea when I wrote about useOptimistic and the spinner I finally deleted; this is the same spirit of replacing ad hoc state with something React understands.

The smaller changes I’d actually check before upgrading

Trusted Types: if your CSP enforces require-trusted-types-for 'script', React previously coerced everything to a string before handing it to the DOM, which turned your TrustedHTML objects back into plain strings the browser rejected. 19.3 passes them through untouched. If you’ve been holding off on that CSP directive because React fought it, that blocker is gone.

Server Components can render a Context imported from a 'use client' module directly, without a Provider 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.

From the changelog, the one I’d flag: Transitions now render independently instead of being entangled, so a slow Transition no longer blocks unrelated ones. That’s a behaviour change, and if you had code accidentally relying on batching across transitions, test it. There’s also a fix for useDeferredValue getting stuck on a stale value, which explains a bug I’d blamed on my own code for a month.

What I’d do this week

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 <ViewTransition> inside startTransition, and check that it still works when the user double-clicks. Then grep for setMounted(true) and try use(browser()) on one of them. If either feels worse, you’ve lost an hour. If they feel better, you’ve probably got a dozen more to convert, and that’s a job I’ve done for a few clients already; details of that kind of work are on my portfolio.

I’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.