React 19.3 shipped on September 9 (19.3.1 followed on October 14). The release notes are long, and reading them is not the same as getting them.
So here's how we learn new stuff at The Release Desk: we build one small project and use every new concept in it for real. This time it's a zoo of famous bugs.
First, a quick hello to each new feature. Then we open the zoo.
Part 1: Meet the five new tricks
1. <ViewTransition>: things move instead of jump
Wrap an element, and React animates it when it appears, disappears or changes. It uses the browser's built-in View Transition API, now stable in React.
<ViewTransition><Card /></ViewTransition>
You'll want it when: a list item opens into a detail page, or a modal pops in.
2. addTransitionType: tell the animation why it's happening
Same state change, different animation. Moving "next" slides left; moving "previous" slides right.
startTransition(() => {
addTransitionType('next');
setPage(p => p + 1);
});
You'll want it when: you have carousels, steppers or tabs.
3. Suspense + ViewTransition: loading that doesn't flicker
Wrap a <Suspense> boundary and the skeleton β content swap becomes a smooth fade, not a pop.
<ViewTransition><Suspense fallback={<Skeleton />}>β¦</Suspense></ViewTransition>
You'll want it when: any data loads after a click.
4. Fragment refs: a ref on a group, no wrapper <div>
Put a ref on a <Fragment> and you can observe, focus or listen to all its children at once. That includes components that don't accept a ref.
<Fragment ref={ref}>{children}</Fragment>
You'll want it when: you'd otherwise add a wrapper <div> that breaks your grid or flex layout.
5. use(browser()): "don't render me on the server"
One line opts a component out of server rendering. The server shows the Suspense fallback; the browser renders the real thing.
use(browser());
You'll want it when: the component needs localStorage, window or the user's time zone. Goodbye, mounted + useEffect trick.
Part 2: Let's understand them with a simple project
Meet the Bug Zoo. Eight famous bugs live in it: Off-by-One, Race Condition, Stale Closure, Hydration Mismatch⦠and the Heisenbug, which only shows up when nobody's looking.
Each new feature does one job in the zoo.

Recorded in Edge from the real app. Avatar morph, direction-aware slides, skeleton β content.
Setup is just Vite plus:
npm i react@19.3.1 react-dom@19.3.1
π Opening a bug: the card morphs into its page (tricks 1 + 2)
Give the avatar the same name on the grid and on the bug's page. React morphs it from one spot to the other.
// On the zoo grid
<ViewTransition name={`bug-${bug.id}`} share="auto" default="none">
<span className="avatar">{bug.emoji}</span>
</ViewTransition>
// On the bug's page: same name, bigger avatar
<ViewTransition name={`bug-${bug.id}`} share="auto" default="none">
<span className="avatar big">{bug.emoji}</span>
</ViewTransition>
Every navigation goes through one function, so it's always a Transition, and always typed:
function go(id, type) {
startTransition(() => {
addTransitionType(type); // 'open' | 'close' | 'next' | 'previous'
setOpenId(id);
});
}
How it works: React snapshots the old screen, swaps the DOM, then animates between the two. The catch: only Transitions animate. A plain setOpenId(id) just jumps.
π Next / Previous: direction-aware slides (trick 2)
<ViewTransition
key={bug.id}
enter={{ next: 'from-right', previous: 'from-left', default: 'none' }}
exit={{ next: 'to-left', previous: 'to-right', default: 'none' }}
>
<article>β¦</article>
</ViewTransition>
Those strings are plain CSS classes:
::view-transition-new(.from-right) { animation: 280ms 80ms ease-out both slide-from-right; }
::view-transition-old(.to-left) { animation: 180ms ease-in both slide-to-left; }
@keyframes slide-from-right { from { transform: translateX(120px); opacity: 0; } }
@keyframes slide-to-left { to { transform: translateX(-120px); opacity: 0; } }
A trap we hit: our first slide looked like a double exposure. The named avatar was animating on its own instead of riding along with its page. default="none" limits it to the morph only, so now it slides with its page.
πͺ² Field notes: calmer loading (trick 3)
Each bug's notes come from a (fake, 900 ms) API:
<ViewTransition update="auto" default="none">
<Suspense fallback={<NotesSkeleton />}>
<FieldNotes id={bug.id} />
</Suspense>
</ViewTransition>
function FieldNotes({ id }) {
const notes = use(fetchNotes(id)); // cached promise
return <dl>β¦</dl>;
}
update="auto" default="none" follows React's own rule of thumb:
- the skeleton appears instantly (no fade)
- skeleton β content animates
- second visit (cached) appears instantly
π·οΈ The "Spotted 4/8" counter: Fragment refs (trick 4)
The header counts the bugs you've scrolled past. That needs an IntersectionObserver on every card, but BugCard doesn't take a ref, and a wrapper div would break the CSS grid.
function Spotter({ onSpot, children }) {
const ref = useRef(null);
useEffect(() => {
const fragment = ref.current;
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) onSpot(e.target.dataset.bug);
}, { threshold: 0.9 });
fragment.observeUsing(io); // observes every first-level child
return () => fragment.unobserveUsing(io);
}, []);
return <Fragment ref={ref}>{children}</Fragment>;
}
In our test the counter went from 4/8 to 8/8 after scrolling down. The same FragmentInstance can also focus(), addEventListener and scrollIntoView across the group.
π¦ The Heisenbug: use(browser()) (trick 5)
The Heisenbug's page shows your local time and time zone. A server can't know those.
import { browser } from 'react-dom';
function Sighting() {
use(browser()); // suspends on the server, not in the browser
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return <p>π¦ Spotted at {time} ({zone}), only in your browser.</p>;
}
<Suspense fallback={<p>π The server looked. The Heisenbug vanished.</p>}>
<Sighting />
</Suspense>
We server-rendered that page with renderToString. The real HTML:
<p class="sighting">π The server looked. The Heisenbug vanished.</p>
In the browser, the same spot reads "Spotted at 12:09 pm (Asia/Calcutta)". The bug only exists when the browser is watching. Fitting.
Bonus: use can sit behind an if, so "skip the server only when there's no default value" is a one-liner.
Before you ship it
| Thing | What happens |
|---|---|
Update not in startTransition
|
No animation. By design. |
| Older browser | View Transitions are Baseline since Oct 2025. Older browsers just skip the animation; the app still works. |
| Tab in the background | The browser skips the transition (we saw this while testing headless). |
| Users who prefer reduced motion | Your job. We added a prefers-reduced-motion rule that turns the animations off. |
Desk verdict: Upgrade to 19.3.1 if you're on React 19 already. Start with use(browser()): it deletes code. Add <ViewTransition> one screen at a time, and always behind startTransition.
Facts come from the React 19.3 release post and our own test run. Cover art: Jan van Kessel, Insects, Butterflies, and a Grasshopper (1664), The Met, CC0.


Top comments (0)