React Activity API: Preserve UI State Without Unmounting

React Activity API: Preserve UI State Without Unmounting

Learn how React's Activity API lets you hide parts of your UI while keeping their state alive โ€” no more losing form data or scroll position when switching tabs.

frontend
August 12, 2026
13 min read

The React Activity API is one of the most practical additions to React's concurrent features toolkit. It solves a deceptively common problem: you want to hide a part of your UI โ€” say, a tab panel or a modal โ€” but you don't want React to throw away its state when it disappears from the screen. Before the Activity API, developers had to reach for awkward workarounds. Now there's a clean, built-in solution.

What Is the React Activity API?

The React Activity API introduces an <Activity> component (sometimes called <Offscreen> in earlier experimental builds) that wraps a subtree of your UI. You can tell React to either show that subtree or hide it. When hidden, React keeps the component tree mounted in memory โ€” meaning all state, refs, and context values are preserved โ€” but it stops rendering the output to the DOM (the actual web page).

Think of it like minimizing a window on your computer. The app is still running in the background; it just isn't visible. When you bring the window back, everything is exactly where you left it. That's exactly what <Activity> does for React components.

A few terms to clarify before we go further: "mounted" means React has created the component and its children and is tracking their state. "Unmounted" means React has destroyed them and their state is gone. "DOM" stands for Document Object Model โ€” the live tree of HTML elements your browser displays. The Activity API keeps components mounted but removes their DOM output when hidden.

Why Does Hiding UI Normally Destroy State?

In React, state lives inside a component. When you conditionally render a component using something like {isVisible && <MyComponent />}, React unmounts <MyComponent> the moment isVisible becomes false. Every piece of state inside it โ€” form inputs, scroll position, fetched data, animation progress โ€” is wiped out.

When isVisible becomes true again, React mounts a brand-new instance of <MyComponent>. It starts from scratch. This is fine for simple cases, but it creates a frustrating user experience when the component holds meaningful state. Imagine a user filling out a multi-field form on Tab A, switching to Tab B to check something, then returning to Tab A to find their form completely empty.

Developers have historically worked around this in a few ways โ€” all of them imperfect:

  • Using CSS display: none to visually hide the component while keeping it mounted (works, but the component still renders and can cause performance issues).
  • Lifting state up to a parent component so it survives the child unmounting (adds complexity and prop-drilling).
  • Storing state in a global store like Redux or Zustand (overkill for local UI state).
  • Using useRef to persist values across mounts (hacky and doesn't work for all state types).

The Activity API is the official, idiomatic answer to this problem.

How the Activity API Works Under the Hood

The <Activity> component accepts a mode prop that controls its behavior. The two primary modes are "visible" and "hidden". When mode is "visible", the wrapped subtree renders normally to the DOM. When mode is "hidden", React keeps the component tree alive in memory but removes its DOM nodes from the page.

This is possible because of React's concurrent rendering architecture. React can maintain multiple versions of a component tree simultaneously โ€” one that's actively displayed and others that are prepared or paused in the background. The Activity API taps into this capability to give developers explicit control over which subtrees are active.

React also fires lifecycle effects differently in hidden mode. When a component transitions from visible to hidden, React runs cleanup functions for useEffect hooks (just like an unmount). When it transitions back to visible, React re-runs those effects (just like a mount). This means your side effects โ€” like event listeners or subscriptions โ€” are properly managed even though the component's state is preserved.

Behaviormode="visible"mode="hidden"
Renders to DOMYesNo
State preservedYesYes
useEffect cleanup runsNo (stays mounted)Yes (on hide)
useEffect re-runs on showN/AYes (on show)
Performance costNormal render costLow โ€” no paint/layout

How to Use the Activity API: A Practical Example

Let's build a simple tabbed interface where each tab panel preserves its state when you switch away. We'll use a form with a text input to make the state preservation obvious.

First, make sure you're using a version of React that includes the Activity API (React 19 or a canary build that supports it). The import path may vary slightly depending on your version.

typescript
// TabsWithActivity.tsx
import { useState } from 'react';
import { unstable_Activity as Activity } from 'react'; // import path for canary/v19

function FormTab() {
  const [text, setText] = useState('');

  return (
    <div>
      <h3>Form Tab</h3>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Type something..."
      />
      <p>You typed: {text}</p>
    </div>
  );
}

function SettingsTab() {
  const [darkMode, setDarkMode] = useState(false);

  return (
    <div>
      <h3>Settings Tab</h3>
      <label>
        <input
          type="checkbox"
          checked={darkMode}
          onChange={(e) => setDarkMode(e.target.checked)}
        />
        {' '}Enable Dark Mode
      </label>
      <p>Dark mode is: {darkMode ? 'ON' : 'OFF'}</p>
    </div>
  );
}

export default function Tabs() {
  const [activeTab, setActiveTab] = useState('form');

  return (
    <div>
      {/* Tab buttons */}
      <nav>
        <button onClick={() => setActiveTab('form')}>Form</button>
        <button onClick={() => setActiveTab('settings')}>Settings</button>
      </nav>

      {/* Each tab is wrapped in Activity */}
      <Activity mode={activeTab === 'form' ? 'visible' : 'hidden'}>
        <FormTab />
      </Activity>

      <Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
        <SettingsTab />
      </Activity>
    </div>
  );
}

In this example, both <FormTab> and <SettingsTab> are always mounted. When you switch tabs, the inactive tab's mode becomes "hidden" โ€” its DOM nodes are removed, but its state (the typed text, the checkbox value) is kept alive. Switch back, and everything is exactly as you left it.

Compare this to the old approach using a conditional render:

typescript
// Old approach โ€” state is LOST when tab changes
{activeTab === 'form' && <FormTab />}
{activeTab === 'settings' && <SettingsTab />}

With the old approach, every tab switch destroys and recreates the component. With <Activity>, the components live on in the background, ready to reappear instantly with their state intact.

When Should You Use the Activity API?

The Activity API is not a replacement for all conditional rendering. It has a memory cost โ€” hidden components stay in memory โ€” so you should use it deliberately. Here are the scenarios where it shines:

  • Tabbed interfaces: Users switch between tabs and expect their work to be preserved.
  • Multi-step wizards: Users navigate back and forth between steps without losing input.
  • Slide-over panels and drawers: A side panel that closes and reopens should remember its scroll position.
  • Search results with filters: Switching between a map view and a list view without re-fetching data.
  • Modals with complex forms: A modal that can be dismissed and reopened without resetting its state.
  • Virtualized lists with detail views: Navigating to a detail page and back without losing the list's scroll position.

Avoid using <Activity> for components that are truly gone from the user's workflow โ€” like a page the user has navigated away from permanently. In those cases, unmounting is correct and freeing the memory is the right call.

Common Mistakes to Avoid With the Activity API

Even a well-designed API can be misused. Here are the pitfalls developers most commonly run into when first adopting the Activity API.

  1. Wrapping everything in Activity: Not every component needs state preservation. Overusing <Activity> keeps unnecessary component trees in memory and can slow down your app. Use it only where state persistence genuinely improves the user experience.
  2. Forgetting that effects still run: When a component goes from hidden to visible, useEffect hooks re-run. If your effect fetches data or sets up a subscription, make sure it handles being called multiple times gracefully โ€” or use useRef to track whether the initial setup has already happened.
  3. Assuming hidden means zero cost: Hidden components don't paint to the screen, but they still exist in React's memory. If a hidden component has expensive state (like a large data structure), that memory is still occupied. Plan accordingly.
  4. Using the wrong import path: The Activity API is still stabilizing. In React 19 canary builds, it may be exported as unstable_Activity. Always check the release notes for your exact React version.
  5. Confusing Activity with CSS visibility: Setting visibility: hidden or display: none via CSS also hides elements visually, but the component still renders fully and its effects run normally. The Activity API is a React-level concept โ€” it's fundamentally different from CSS hiding.
  6. Not testing the hidden-to-visible transition: Always test what happens when a hidden component becomes visible again. Effects re-run, and if your component makes network requests in a useEffect, you might trigger duplicate API calls.

Activity API vs. Other State Persistence Approaches

It's worth comparing the Activity API to the alternatives so you can make an informed choice for your project.

ApproachState PreservedDOM Removed When HiddenComplexityBest For
Conditional render (&&)NoYesLowTruly temporary UI
CSS display:noneYesNo (still in DOM)LowSimple toggles, small trees
Lift state to parentYesYesMediumShallow component trees
Global store (Redux/Zustand)YesYesHighApp-wide shared state
Activity APIYesYesLowComplex local UI state

The Activity API hits a sweet spot: it preserves state and removes DOM nodes (so there's no layout/paint cost for hidden content), all with minimal code complexity. The CSS display: none trick preserves state too, but the hidden component still exists in the DOM and still participates in layout calculations, which can cause subtle bugs and performance issues in large trees.

Frequently Asked Questions About the React Activity API

Q: Is the Activity API stable and ready for production?

As of React 19, the Activity API is available but may still be exported under an unstable_ prefix in some builds, signaling that its exact API surface could change in minor releases. Check the official React changelog for your version before shipping it in a production app. The underlying concept is solid and the React team has committed to it, but the exact prop names and import paths may be finalized in a later release.

Q: Does the Activity API work with React Server Components?

The Activity API is a client-side feature. It manages client component trees that live in the browser. React Server Components render on the server and send HTML/data to the client โ€” they don't have state in the same sense. You would use <Activity> inside a Client Component, not a Server Component.

Q: Will hidden Activity components block the main thread?

No. React's concurrent renderer processes hidden Activity trees at a lower priority than visible content. React will yield to more urgent work (like user interactions) before updating hidden trees. This means hiding a component with <Activity> is much more performance-friendly than keeping it visible but off-screen.

Q: Can I nest Activity components inside each other?

Yes, nesting is supported. A child <Activity> inside a hidden parent <Activity> will remain hidden regardless of its own mode prop โ€” a hidden parent overrides a visible child. When the parent becomes visible again, the child's own mode takes effect.

Q: How is this different from React's `<Suspense>` component?

<Suspense> is about handling async loading states โ€” it shows a fallback while waiting for data or lazy-loaded components. <Activity> is about controlling visibility and state persistence for already-loaded components. They solve different problems and can be used together: you might wrap a lazy-loaded tab panel in both <Suspense> (for the initial load) and <Activity> (for subsequent hide/show cycles).

What to Try Next

Now that you understand the React Activity API, the best next step is to find a place in your own project where state loss is causing a poor user experience โ€” a tabbed layout, a multi-step form, or a collapsible panel โ€” and replace the conditional render with <Activity>. The change is usually just a few lines of code, and the improvement in user experience is immediate and tangible.

From there, explore how <Activity> interacts with useEffect in your specific use case. Pay attention to which effects you want to re-run on show and which ones you want to run only once. Using a useRef flag to track first-mount vs. re-show is a common pattern you'll reach for quickly.

Finally, keep an eye on the official React documentation and release notes. The Activity API is part of a broader set of concurrent features โ€” including useTransition, useDeferredValue, and <Suspense> โ€” that work best together. Understanding the full picture will make you a significantly more effective React developer.