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.



