Skip to main content

React 19.3: View Transitions and Fragment Refs Go Stable

· 10 min read
Gergely Sipos
Frontend Architect

React 19.3 shipped on September 9, 2026. Two APIs that have been sitting in the experimental drawer since the April 2025 React Labs post — <ViewTransition> and Fragment Refs — are now stable, joined by a browser() escape hatch for SSR, Trusted Types passthrough, and Context rendering in Server Components. There are no breaking changes, no deprecations, no codemods, and no upgrade guide, because none are needed. The interesting question isn't whether to bump the version. It's which of these APIs earn a place in your codebase.

What's actually in 19.3​

FeatureWhat it doesAdopt now?
<ViewTransition>Stable wrapper over the browser View Transition APIYes, for new interaction work
addTransitionTypeLabels why a transition happened, so it can animate accordinglyWith <ViewTransition>
Fragment Refsref on <Fragment>, operating on children as a groupWhen the alternative is a wrapper <div>
browser()First-class opt-out of SSR for a subtreeYes — strictly better than the hack
Trusted Types passthroughWorks under require-trusted-types-for 'script'Only if you enforce that CSP
Context in Server ComponentsRender a client Context directly from a Server ComponentIf you're on RSC

The release is plain v19.3.0, cut by @eps1lon — see the GitHub release notes for the complete change list.

<ViewTransition> is stable​

<ViewTransition> wraps a piece of UI and hands its enter, exit, move, and resize animations to the browser's View Transition API. React picks one of four animation kinds automatically: enter when the element is added, exit when it's removed, update when children change style or content, and share when a named ViewTransition disappears in one place and reappears in another.

import { ViewTransition } from "react";

{
isShowing && (
<ViewTransition>
<Component />
</ViewTransition>
);
}

The rule that will trip you up first: <ViewTransition> only animates inside a Transition. That means startTransition, a <Suspense> reveal, or a useDeferredValue update. Urgent updates deliberately don't animate — if your animation isn't firing, that's the first thing to check.

The default animation is a cross-fade. You customise it with a View Transition Class and CSS, or imperatively via the onEnter, onExit, onShare, and onUpdate event props using the Web Animations API.

When the same state change needs to animate differently depending on direction — a carousel is the canonical case — addTransitionType labels the reason:

startTransition(() => {
addTransitionType("next");
setCurrentSlide((c) => c + 1);
});
<ViewTransition
enter={{ 'next': 'from-right', 'previous': 'from-left' }}
exit={{ 'next': 'to-left', 'previous': 'to-right' }}
>

React also emits each transition type as a native browser view transition type, so you can scope plain CSS with :active-view-transition-type(...) from CSS View Transitions Level 2.

One limitation to note up front: this is DOM only. React Native and other platforms are still in progress, so RN projects get nothing here yet.

This kills the last excuse​

In State of CSS 2026 we wrote about a specific failure mode: View Transitions have been Baseline Newly available since 2025-10-14, and are still the #2 most-avoided CSS feature on browser-support grounds at 9%. Developers are avoiding a feature that works everywhere, because it landed on internal "don't use" lists before Firefox shipped and nobody went back to revisit them.

React just removed the other half of that excuse. The reason to skip View Transitions was never browser support and is now not ergonomics either — there is a stable, first-party React API for it in the version you're already on.

Suspense + View Transitions needs discipline​

Wrapping a <Suspense> boundary in <ViewTransition> animates the fallback-to-content swap. This is the part teams will get wrong, so it's worth being deliberate about. React states three UX principles for it:

  • Fallbacks should appear immediately, without animation
  • A fallback should update to its final content with animation
  • Children that don't suspend should appear immediately, without animation

The pattern that produces exactly that behaviour:

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

React's own guidance: animations with Suspense work best when used sparingly, and should be avoided for cached UI that would otherwise appear instantly. Animating a skeleton into content that was already in memory makes your app feel slower, not smoother.

There's a genuinely new capability hiding in here too. Wrapping an <img>, or a font declared via <style>, inside a <ViewTransition> opts it into triggering Suspense while it loads. That removes the browser's default flicker and lets you coordinate a single loading sequence across data, images, and fonts — see Suspense: waiting for a font to load.

Fragment Refs​

Fragment Refs answer a narrow but extremely common problem: I need a ref, but I'd have to add a <div> to get one. Two cases dominate — a component that renders a group of siblings with no single parent element, and a third-party component that doesn't forward ref.

Passing a ref to <Fragment> now gives you a FragmentInstance that operates on the fragment's DOM children as a group, without adding an element to the tree:

const fragmentRef = useRef(null);

useEffect(() => {
fragmentRef.current.focus();
}, []);

return <Fragment ref={fragmentRef}>{/* ... */}</Fragment>;

The FragmentInstance surface:

  • addEventListener, removeEventListener, dispatchEvent — first-level children
  • focus, focusLast, blur — depth-first across nested children
  • observeUsing / unobserveUsing — attach an IntersectionObserver or ResizeObserver
  • getClientRects, getRootNode, compareDocumentPosition, scrollIntoView — first-level children

For agency work, observeUsing is the one to reach for. Viewport analytics, lazy-loaded sections, scroll spy navigation — all of it previously required a wrapper div that existed purely to be observed, and all of it now doesn't.

browser() retires the isMounted hack​

Every SSR codebase has this in it somewhere:

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;

React 19.3 replaces it with an actual API. browser() is a new export from react-dom, and use(browser()) suspends on the server but not on the client:

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

function Component() {
use(browser());
// browser-only code
}

During SSR the nearest Suspense boundary's fallback goes into the HTML. After hydration, the component renders normally. That alone is cleaner than the hack, but the part that goes beyond it is that browser() — like other use calls — can be called conditionally:

function useBrowserQuery(query, options) {
if (options.initialData === undefined) {
use(browser());
}
return useQuery(query, options);
}

That's a data-fetching wrapper that server-renders when it has seed data and defers to the browser when it doesn't. You could not express that with a mount flag. If you're instrumenting SSR, react-dom/server APIs also gained an onBrowserBailout option so you can observe when a subtree defers to the browser.

We flagged browser() in our August 31 newsletter roundup while it was still Canary-only. It's shipped.

Two smaller wins​

Trusted Types​

The change here is narrow and worth stating precisely. React used to coerce values with '' + value before handing them to DOM APIs, which turned TrustedHTML, TrustedScript, and TrustedScriptURL objects back into plain strings — exactly the strings the browser then rejected. React 19.3 passes them through uncoerced.

This only matters if you enforce Content-Security-Policy: require-trusted-types-for 'script'. If you do, React is now compatible with it. See MDN's Trusted Types API for the browser side, and our own Trusted Types guide — whose "React does not have native integration" line is superseded by this release and is being updated.

Context in Server Components​

Server Components can now import a Context from a 'use client' module and render it directly, with no wrapper Provider component in between:

server-component.js
import { UserContext } from "./user-context"; // 'use client' module

export async function Layout({ children }) {
const currentUser = await getCurrentUser();
return <UserContext value={currentUser}>{children}</UserContext>;
}

The constraint that remains: Server Components still cannot create Context.

Parallel transitions, and the rest of the performance work​

Transitions now render independently rather than being entangled into a single render. In practice: a slow Transition no longer holds up unrelated ones.

Our August 31 roundup noted this was on by default in Canary and "potentially landing in React 19.3". It landed. In the interest of not overselling it — React published no benchmark numbers for this change, and describes it purely qualitatively. Treat it as a correctness-shaped improvement to responsiveness, not a measured speedup.

The smaller performance items:

  • resize event updates are batched until the next frame
  • innerHTML is skipped when it hasn't changed
  • Explicitly preloaded stylesheets are tracked, avoiding unnecessary suspension
  • A batch of RSC/Flight improvements to payload parsing, model serialisation, and reply decoding

What to watch when you upgrade​

There are no breaking changes, no deprecations, no removals, and no codemods. That's the headline. But three things can still surprise you, and all three are dev-only:

  1. Strict Mode now double-invokes Effects during hydration, matching the behaviour of client-rendered roots (and it also double-invokes after Fast Refresh). This is the most likely source of "did 19.3 break something?" tickets. It doesn't break anything in production — it surfaces latent non-idempotent Effects that your SSR app was quietly getting away with.
  2. A new DEV-only warning fires when a component appears to have been unblocked by calling use() conditionally. It may trigger on existing code.
  3. useActionState error message strings were renamed from "form state" to "action state". Any test asserting on those exact messages will fail.

On the positive side of the dev experience, there are several Fast Refresh fixes covering lazy(), memo(), and edits that change a component's kind — straightforwardly better, nothing to do.

npm install react@19.3.0 react-dom@19.3.0

Practical next steps​

  • Upgrade: yes. Low risk, no migration work, no guide to read. Slot it into the next maintenance window.
  • <ViewTransition>: adopt for new interaction work, use it sparingly, and read the Suspense guidance before you wrap a boundary. This is not a retrofit project.
  • browser(): adopt on sight. It replaces a pattern that exists in every SSR codebase we run.
  • Fragment Refs: adopt when the alternative is a wrapper div or forking a library.
  • React Native projects: <ViewTransition> is DOM-only for now, so there's nothing to plan for yet.
  • Concrete internal candidate: Vacation Dashboard runs React 19 on Vite and is the obvious first upgrade. See also our React tech stack page.

Further reading: