How React 19.2 `<Activity>` and `<ViewTransition>` Structurally Solve Disappearing Form State in Tab and Modal Transitions
If you've ever filled out a three-step wizard modal all the way to the last step, accidentally closed it, and had to start over from scratch when you reopened it — you know exactly what this feels like. I've tried alternating between display: none and visibility: hidden in production, but the tradeoff was always there: state is preserved, but the useEffect inside hidden components keeps running and firing unnecessary API calls. Conditional rendering with && is clean, but it wipes out the user's context entirely.
With the <Activity> component released as stable in React 19.2, there's now a fairly clean solution to this problem. The idea is to decouple a component's lifetime from its visibility — and when you pair it with the experimental <ViewTransition> API, you can even get smooth enter/exit animations without any third-party animation library. By the end of this post, you'll be able to apply three patterns — tabs, modals, and pre-rendering — directly in your code, and you'll understand at a mechanical level how they solve the limitations of existing approaches.
This post is written for intermediate React developers and above who are reasonably familiar with
useState,useEffect, and Concurrent features (startTransition). I'll briefly explain each concept as it comes up for those encountering it for the first time.
Core Concepts
<Activity>: Putting a Component to Sleep Instead of Killing It
Let's first look at the limitations of the three existing approaches.
| Approach | State Preserved | Effects Cleaned Up | Problem |
|---|---|---|---|
&& conditional rendering |
❌ | ✅ | Every transition resets state, DOM, and scroll position from scratch |
display: none |
✅ | ❌ | useEffect keeps running while hidden, causing unnecessary API calls |
<Activity mode="hidden"> |
✅ | ✅ | — |
<Activity> satisfies both conditions simultaneously with a single mode prop.
| mode | On Screen | Effects | React State / DOM |
|---|---|---|---|
"visible" |
Renders normally | Mount/update runs normally | Preserved |
"hidden" |
React applies inline display: none internally |
Runs cleanup and suspends | Preserved |
In the "hidden" state, Context updates are still received at a lower priority. It's not fully disconnected — think of it as the scheduling priority being lowered compared to components currently on screen.
// Basic usage
import { Activity } from 'react'; // Available in React 19.2 stable
<Activity mode={isActive ? 'visible' : 'hidden'}>
<SomeHeavyComponent />
</Activity>Offscreen Architecture: The internal codename for
<Activity>was "Offscreen." It's a concept the React team has been working on for a long time, and it graduated to a public API in React 19.2. This is the first public signal that React is moving toward independently controlling a component's lifetime and visibility.
<ViewTransition>: A Declarative Way to Animate State Changes
The browser's View Transition API is a Web API that captures screenshots of the DOM before and after a change via document.startViewTransition() and connects them with CSS animations. But wiring this directly to React's rendering cycle turns out to be trickier than it sounds when it comes to timing.
<ViewTransition> lets React handle that wiring for you. There is one important condition, though: the state change must happen inside startTransition for the animation to activate.
startTransitionis one of React's Concurrent features. It lowers the priority of a state update so the UI stays responsive even during rendering.<ViewTransition>only works within this Concurrent rendering cycle.
import { startTransition } from 'react';
// ❌ ViewTransition animation will NOT fire
setActiveTab('settings');
// ✅ ViewTransition animation WILL fire
startTransition(() => {
setActiveTab('settings');
});The connection point with <Activity> is clear. When mode changes from hidden → visible, an enter animation is automatically triggered; when it changes from visible → hidden, an exit animation fires.
Current Status (2025-10):
<Activity>is importable directly from'react'in React 19.2 stable.<ViewTransition>, on the other hand, is still an experimental API — in the stable channel it's exposed asunstable_ViewTransition, or you need to install the experimental channel package (react@experimental) separately. Keep in mind the possibility of breaking changes before using it in production.
Browser Support: The View Transition API is supported in Chrome 111+, Safari 18+; Firefox is currently in development. In unsupported browsers, transitions happen instantly without animation (progressive enhancement), so the feature itself still works correctly.
Practical Application
Example 1: Tab Switching — Preserving Form State and Scroll Position
This is a situation you run into constantly in production. When there are filter selections or in-progress content across tabs, switching away and coming back resets everything.
import { useState, startTransition } from 'react';
import { Activity } from 'react';
// ViewTransition requires the experimental channel package or unstable_ prefix
import { unstable_ViewTransition as ViewTransition } from 'react';
const tabs = [
{ id: 'home', label: 'Home' },
{ id: 'search', label: 'Search' },
{ id: 'settings', label: 'Settings' },
];
export function TabbedLayout() {
const [activeTab, setActiveTab] = useState('home');
function handleTabChange(tabId: string) {
// Must wrap in startTransition for ViewTransition animations to work
startTransition(() => {
setActiveTab(tabId);
});
}
return (
<div>
<nav>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
aria-selected={activeTab === tab.id}
>
{tab.label}
</button>
))}
</nav>
{tabs.map((tab) => (
<Activity
key={tab.id}
mode={activeTab === tab.id ? 'visible' : 'hidden'}
>
{/* enter/exit animations automatically triggered on mode change */}
<ViewTransition>
{/* connect to CSS pseudo-selectors via view-transition-name */}
<TabContent
tabId={tab.id}
style={{ viewTransitionName: `tab-${tab.id}` }}
/>
</ViewTransition>
</Activity>
))}
</div>
);
}To customize enter/exit animations in CSS, use the name value you assigned to viewTransitionName as the argument to the View Transition pseudo-selector. This is not a CSS class selector, so do not prepend a dot (.).
/* In JSX: style={{ viewTransitionName: 'tab-home' }} */
/* In CSS: use the name value as-is, not as a class */
::view-transition-new(tab-home) {
animation: slide-in 0.2s ease-out;
}
::view-transition-old(tab-home) {
animation: slide-out 0.2s ease-in;
}
@keyframes slide-in {
from { transform: translateX(16px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-16px); opacity: 0; }
}I spent a while debugging this myself when I first wrote it as ::view-transition-new(.tab-content) like a CSS class selector and nothing animated at all. view-transition-name is a unique name identifier, not a CSS class, so you use the name value itself in the pseudo-selector — no dot.
Example 2: Show/Hide Modal — Preserving In-Progress Form Content
The user experience is much more natural when a modal reopens with previously entered content still intact. This is especially impactful for long forms or multi-step wizards — let's open up ContactModal and see exactly how it works.
import { useState, startTransition } from 'react';
import { Activity } from 'react';
import { unstable_ViewTransition as ViewTransition } from 'react';
// Modal interior: form state survives here
function ContactModal({ onClose }: { onClose: () => void }) {
const [name, setName] = useState('');
const [message, setMessage] = useState('');
return (
<div role="dialog" aria-modal="true" aria-label="Contact Us">
<h2>Contact Us</h2>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Enter your message"
rows={4}
/>
<div>
<button onClick={onClose}>Close</button>
<button type="submit">Send</button>
</div>
</div>
);
}
export function ModalExample() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => startTransition(() => setIsOpen(true))}>
Contact Us
</button>
<Activity mode={isOpen ? 'visible' : 'hidden'}>
<ViewTransition>
<ContactModal onClose={() => startTransition(() => setIsOpen(false))} />
</ViewTransition>
</Activity>
</>
);
}I was confused about this at first too: when mode="hidden", the useEffect inside the modal runs its cleanup and suspends — but React state managed by useState, like name and message, stays alive. When you switch back to mode="visible", Effects remount and display the previous state as-is. Even if a user has filled in half their name and message and closes the modal, their input is still there when they reopen it.
Portal caveat: There is a known bug (GitHub Issue #34769) where using
createPortaltogether with<ViewTransition>causes z-index layer ordering to break. If you're rendering modals with portals, it's recommended to verify this separately until the fix is released.
Example 3: Pre-rendering to Improve Navigation Speed
This is a pattern for rendering content in the background ahead of time. By initially rendering with mode="hidden", code, data, and images are pre-loaded at low priority on the client, enabling near-instant screen transitions when the user actually navigates.
import { useState, startTransition } from 'react';
import { Activity } from 'react';
import { unstable_ViewTransition as ViewTransition } from 'react';
export function ProductLayout({ productId }: { productId: string }) {
const [showDetail, setShowDetail] = useState(false);
return (
<div>
{/* List view — always visible */}
<ProductList
onSelect={() => startTransition(() => setShowDetail(true))}
/>
{/* Detail view — rendered in the background at low priority from the start */}
<Activity mode={showDetail ? 'visible' : 'hidden'}>
<ViewTransition>
<ExpensiveProductDetail productId={productId} />
</ViewTransition>
</Activity>
</div>
);
}One thing to note: data fetching inside useEffect (e.g., TanStack Query's default useQuery behavior) won't benefit directly from pre-loading, since Effects are suspended during mode="hidden". The synergy is much greater when fetching with Suspense-based patterns (like TanStack Query's useSuspenseQuery).
Suspense: A React feature for declaratively managing loading states when handling data fetching or code splitting asynchronously. It integrates with Concurrent rendering and is the best way to take full advantage of
<Activity>pre-rendering.
Pros and Cons
Pros
| Item | Details |
|---|---|
| State preservation | Form input values, scroll positions, and internal UI toggle state are maintained across tab/modal transitions |
| Effects cleanup | useEffect cleanup runs on hide transition, blocking unnecessary API calls and event listeners |
| Pre-rendering | Initial render with mode="hidden" triggers background loading → fast display on transition |
| Animation integration | Combined with <ViewTransition>, enter/exit animations trigger automatically — no extra library needed |
| Background updates | Hidden components still receive Context updates at low priority, keeping data fresh |
Cons and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Increased memory | Hidden components stay in memory; having many hidden Activity components simultaneously can significantly increase memory usage | Consider a hybrid strategy: apply only to 2–3 frequently switched tabs, and keep the rest as conditional rendering |
| ViewTransition is experimental | <ViewTransition> is still an experimental API with potential for breaking changes |
Be cautious about production adoption scope until it becomes importable without the unstable_ prefix |
| startTransition required | ViewTransition animations won't fire with a plain setState |
Make wrapping tab/modal state changes in startTransition a team-wide convention |
| Effect-based fetching not supported | Data fetching inside useEffect won't benefit from pre-rendering |
Consider migrating to Suspense-based fetching (e.g., useSuspenseQuery) |
| Portal conflict | Known z-index layer ordering bug when mixing createPortal + <ViewTransition> (Issue #34769) |
Until fixed, always test separately when applying ViewTransition to portal modals |
Common Mistakes When Applying This
-
Forgetting
startTransition: If you callsetStatein a tab-switching handler withoutstartTransition,<ViewTransition>won't animate no matter how well it's positioned. This is easy to lose significant time debugging. I personally wasted 30 minutes on this alone when I first applied it. -
Adding a dot (
.) to CSS pseudo-selectors: Writing::view-transition-new(.tab-content)like a CSS class selector will produce zero animation. The argument to the View Transition pseudo-selector is the name value you assigned toviewTransitionNameitself (tab-content), not a CSS class. No dot. -
Wrapping every tab in
<Activity>: With 10 or 20 tabs, keeping all of them alive in<Activity>is bad from a memory standpoint. Even with 5 dashboard tabs, I noticed a tangible memory increase. A hybrid strategy — applying<Activity>only to the 2–3 most frequently switched core tabs and keeping the rest as conditional rendering — is more realistic. -
Placing
<ViewTransition>outside<Activity>: If<ViewTransition>is outside<Activity>, the enter/exit triggers won't connect properly at the point of mode transition. It's recommended to place<ViewTransition>as a direct child inside<Activity>.
Closing Thoughts
<Activity> is not just a show/hide toggle — it's a structural turning point where React has begun independently controlling a component's lifetime and its visibility. There's a strong chance that many more UX patterns, like pre-rendering and offscreen rendering, will open up on top of this foundation. Add <ViewTransition> to the mix, and you can declaratively express smooth UI transitions without any third-party animation library.
Three steps to get started right now:
-
Upgrade to React 19.2: Install with
pnpm add react@^19.2.0 react-dom@^19.2.0, then look for tabs and modals currently handled withdisplay: noneor&&conditional rendering. Components where state preservation matters are the top candidates. -
Apply
<Activity>first: Replace with<Activity mode={...}>without<ViewTransition>first, and verify that state is being preserved and that memory usage isn't ballooning. You can watch rendering priority changes in real time using the Profiler in React DevTools. -
Add
<ViewTransition>and customize CSS: If you need the<ViewTransition>experimental build, install it withpnpm add react@experimental react-dom@experimental, then wrap your state changes instartTransitionand add<ViewTransition>. Use the::view-transition-new(name)and::view-transition-old(name)pseudo-selectors to tune enter/exit animations to match your team's design system. Working in the browser DevTools Animations panel to inspect timing in real time makes the process much smoother.
Next post: How to integrate the View Transition API for page-level route transitions using React Router v7's
viewTransitionprop and theuseViewTransitionState()hook.
References
- React Labs: View Transitions, Activity, and more | React Official Blog
- React 19.2 Official Release Notes | React Official Blog
<Activity>| React Official Docs<ViewTransition>| React Official Docs- React View Transitions and Activity API tutorial | LogRocket Blog
- React 19.2 is here: Activity API, useEffectEvent, and more | LogRocket Blog
- The Complete Guide to React ViewTransition | DEV Community
- Stop Losing UI State: A Practical Guide to React's New
<Activity>Component | Medium - Bug: ViewTransition animations broken when using React Portal | GitHub Issues
- View Transitions | React Router Official Docs
- ViewTransition | MDN Web Docs