React 19.3: View Transitions and Fragment Refs Go Stable
Sep 11, 2026- Sergio Zabala
Two APIs That Spent a Year in Experimental
React 19.3 shipped just a couple of days ago, and it graduates two pieces the React team had been building toward since their April 2025 labs post: the <ViewTransition> component and Fragment Refs. Both move from experimental to stable in this release, with no breaking changes. If you’re coming from 19.2 (the one that brought <Activity>, useEffectEvent, and cacheSignal), this is the natural continuation of that same thread: animations and focus/measurement handling without stepping outside React’s declarative model.
<ViewTransition>: Animating Without Leaving React
The <ViewTransition> component wraps another component and uses the browser’s View Transition API to automatically animate four situations: the component entering (mounting), exiting (unmounting), updating (its content or style changes), or being shared (a named <ViewTransition> moves from one place in the tree to another).
import { ViewTransition } from 'react';
{isShowing && (
<ViewTransition>
<Component />
</ViewTransition>
)}
There’s a requirement that’s easy to miss: the animation only fires if the update that triggers it is marked as a Transition, meaning it comes from startTransition(), a <Suspense> reveal, or useDeferredValue(). A regular setState stays immediate and doesn’t animate anything, precisely so it doesn’t introduce latency into interactions that need to feel instant.
To customize the animation you have two paths: CSS classes tied to the transition’s lifecycle, or the onEnter, onExit, onShare, and onUpdate events if you’d rather drive the animation with the Web Animations API directly.
addTransitionType: Same Transition, Different Animation Depending on the Cause
What’s interesting in this release is that knowing what changed is no longer enough — you also want to know why it changed. addTransitionType lets you tag the cause of a Transition so the same <ViewTransition> animates differently depending on context:
function nextSlide() {
startTransition(() => {
addTransitionType('next');
setCurrentSlide(c => c + 1);
});
}
function previousSlide() {
startTransition(() => {
addTransitionType('previous');
setCurrentSlide(c => c - 1);
});
}
<ViewTransition
enter={{ next: 'from-right', previous: 'from-left' }}
exit={{ next: 'to-left', previous: 'to-right' }}
>
<Page />
</ViewTransition>
The browser exposes that type through the :active-view-transition-type(...) pseudo-selector, so you can also resolve the variation purely in CSS if you’d rather.
Suspense Integration
Wrapping a <Suspense> inside a <ViewTransition> animates the transition from fallback to final content as an update. The team’s own recommendation is explicit: the fallback should appear immediately, with no animation, and only the fallback-to-final-content transition should animate. To pull that off without stray animations sneaking in:
<ViewTransition update="auto" default="none">
<Suspense fallback={<Fallback />}>
<Component />
</Suspense>
</ViewTransition>
It’s the same pattern worth using for images and fonts: wrapping them in <ViewTransition> plus <Suspense> avoids the half-loaded-content flicker.
Fragment Refs: Refs Without a Filler Div
This is, to me, the change with the most quietly outsized impact in this release. Until now, if a component rendered several siblings without a single DOM parent, you had no way to attach a ref to it: the only way out was wrapping everything in a <div>, and that div would go on to interfere with your CSS (grids, flexbox, direct-sibling selectors…).
React 19.3 lets you pass a ref directly to a <Fragment>:
function Component() {
const fragmentRef = useRef(null);
useEffect(() => {
fragmentRef.current.focus();
}, []);
return (
<Fragment ref={fragmentRef}>
{posts.map(post => (
<Heading key={post.id}>{post.title}</Heading>
))}
</Fragment>
);
}
That ref doesn’t point to a real DOM node (the Fragment doesn’t create one), but to a FragmentInstance that operates on the set of children as a group: focus() focuses the first focusable child, focusLast() the last one, getClientRects() measures all first-level children, and observeUsing() / unobserveUsing() let you connect an IntersectionObserver or ResizeObserver without manually iterating the children. A typical use case, an InView component that detects whether any of its children are in the viewport:
export default function InView({ onChange, children }) {
const fragmentRef = useRef(null);
useLayoutEffect(() => {
const visible = new Set();
const observer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) visible.add(e.target);
else visible.delete(e.target);
});
onChange(visible.size > 0);
});
fragmentRef.current.observeUsing(observer);
return () => fragmentRef.current.unobserveUsing(observer);
}, [onChange]);
return <Fragment ref={fragmentRef}>{children}</Fragment>;
}
Before this, that component would have needed a DOM wrapper just to have something to attach the observer to. Now the Fragment fills that role without touching the layout at all.
browser(): An Explicit Opt-Out From Server Rendering
Anyone who’s done SSR has run into the same problem: a component that depends on a browser API (localStorage, the local timezone, window) has nothing sensible to render on the server. The usual patch was the mounted/isBrowser pattern, forcing a second client-side render through a useEffect. React 19.3 solves it natively with browser():
import { use } from 'react';
import { browser } from 'react-dom';
function TimeZone() {
use(browser()); // suspends on the server, doesn't suspend on the client
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
return <p>{timeZone}</p>;
}
On the server, use(browser()) triggers Suspense and the fallback ships in the initial HTML. On the client, after hydration, it doesn’t suspend and the component renders normally. It’s the same idea a custom useBrowserQuery hook can build on to decide whether it needs initial data from the server or can resolve everything on the client, without duplicating environment-detection logic in every component.
Two Smaller Changes Worth Knowing About
- Real Trusted Types support: React no longer forces values through string coercion (
'' + value) before handing them to the DOM.TrustedHTML,TrustedScript, andTrustedScriptURLobjects now pass through unchanged, which makesContent-Security-Policy: require-trusted-types-for 'script'actually usable with React, no workarounds needed. - Direct Context in Server Components: you no longer need an intermediate
'use client'component just to wrap aContext.Provider. A Server Component can import theContextfrom a'use client'module and render it directly:
// user-context.js
'use client';
export const UserContext = createContext(null);
// layout.js (Server Component)
import { UserContext } from './user-context';
export async function Layout({ children }) {
const currentUser = await getCurrentUser();
return <UserContext value={currentUser}>{children}</UserContext>;
}
Conclusion
None of these APIs reinvent how React is written: they extend the same declarative model into two areas that used to sit outside its reach — native browser animations, and focus/measurement handling over groups of nodes with no common parent. What matters most about 19.3 isn’t any single feature, it’s the direction it confirms: after labs, then experimental, and now stable, the React team keeps treating visual transitions and low-level DOM access as core parts of the library, not a layer you’re left to hand off to Framer Motion or manual refs. If your app hand-rolls page transitions in CSS, or drops in filler divs just to have something to attach a ref to, this release probably just deleted that code for you.