React 19.2 Activity Component: A Complete Guide

By LearnWebCraft Team17 min readIntermediate
react 19activity componentperformancestate managementoffscreen ui

Introduction: What is the React 19.2 Activity Component?

You know that sinking feeling. You’re halfway through filling out a massive, multi-step form in a dashboard. You’re three paragraphs deep into a text area, crafting the perfect response. Then, purely by reflex, you click over to the "Settings" tab to check a quick configuration.

You switch back.

The text area is empty. The form is reset. Your perfect response? Gone into the digital void.

If you've been building React applications for any length of time, you know exactly why this happens. When you stopped rendering a component—when you "unmounted" it—React faithfully destroyed it. It cleaned up the DOM, wiped the memory, and nuked the local state. Technically, it was doing its job. But from a user experience perspective? It feels like a disaster.

For years, we’ve battled this. We’ve lifted state up to global contexts where it really doesn't belong. We’ve wrestled with complex caching libraries. We’ve abused display: none and watched our performance metrics tank because the browser was forced to manage thousands of invisible DOM nodes.

Enter the React 19.2 Activity Component.

This is the feature I've been waiting for—and I don't say that lightly. The <Activity> component (you might remember it being teased in experimental channels as "Offscreen") allows you to mark a part of your UI as "inactive" without unmounting it. It tells React, "Hey, don't kill this component. Just put it to sleep. Keep its state alive, keep its position in the tree, but stop rendering it to the DOM."

It is the native, performant answer to the "keep-alive" problem that has plagued Single Page Applications (SPAs) since day one. It’s not just a utility; it’s a fundamental shift in how we think about the lifecycle of our views.

In this guide, we aren't just going to skim the docs. We’re going to tear this component apart. We’ll see how it works, why it’s faster than your current hacks, and how to build silky-smooth interfaces that feel more like native apps than websites.

So, grab a coffee. Let's fix that vanishing state problem once and for all.

The Problem It Solves: State Preservation for Offscreen UI

To truly appreciate the elegance of the React 19.2 Activity Component, we have to be honest about the messy solutions we’ve been using up until now.

Historically, React developers have been stuck between a rock and a hard place when dealing with tabbed interfaces, multi-step wizards, or complex navigations. We basically had two choices, and frankly, both of them came with some pretty heavy downsides.

Option 1: Conditional Rendering (The "Unmount" Strategy)

This is the standard React pattern we all learn on day one.

{activeTab === 'dashboard' && <Dashboard />}
{activeTab === 'settings' && <Settings />}

The Good: It keeps the DOM light. When activeTab is 'dashboard', the Settings component essentially doesn't exist. Browser memory usage stays low.

The Bad: State destruction. The moment you switch to 'settings', the Dashboard component is destroyed. If the user had scrolled down a list, expanded an accordion, or typed into an input inside Dashboard, all of that is lost. When they come back, the component mounts from scratch. It’s jarring and feels "cheap."

Option 2: CSS Hiding (The "Display None" Strategy)

This is the brute-force workaround we use when we get desperate.

<div style={{ display: activeTab === 'dashboard' ? 'block' : 'none' }}>
  <Dashboard />
</div>
<div style={{ display: activeTab === 'settings' ? 'block' : 'none' }}>
  <Settings />
</div>

The Good: State preservation! Since the components never unmount, the state stays exactly as the user left it.

The Bad: Performance suicide. Even though the user can't see the Settings tab, it is still fully mounted in the DOM. If Settings contains a heavy data grid or complex charts, the browser is still paying the memory cost for those DOM nodes. Worse, if that hidden component updates (receives new props or context), React still has to reconcile it. You end up burning CPU cycles on invisible pixels.

The Activity Solution

The <Activity> component cuts right through the middle of this dilemma. It gives us the state preservation of Option 2 with the performance benefits roughly comparable to Option 1.

When you wrap a component in <Activity mode="hidden">, React effectively "deprioritizes" it. It detaches the underlying DOM nodes (or sets them aside) so the browser layout engine doesn't have to deal with them, but it keeps the Fiber tree (React's internal representation of your UI) intact in memory.

The state stays. The DOM cost goes away. It is the best of both worlds, and it’s finally native.

Getting Started: Basic Implementation and Syntax

Alright, enough theory. Let’s look at the code. Implementing the React 19.2 Activity Component is deceptively simple given how much heavy lifting it does under the hood.

The API surface area is surprisingly small. You import Activity from the react package, and you control its visibility via a mode prop.

Here is the "Hello World" of Activity components:

import { useState, Activity } from 'react';
import UserProfile from './UserProfile';
import UserSettings from './UserSettings';

function App() {
  const [currentTab, setCurrentTab] = useState('profile');

  return (
    <div className="app-container">
      <nav>
        <button onClick={() => setCurrentTab('profile')}>Profile</button>
        <button onClick={() => setCurrentTab('settings')}>Settings</button>
      </nav>

      <main>
        {/* 
          Instead of unmounting, we change the mode.
          When mode is 'hidden', the component sleeps.
        */}
        <Activity mode={currentTab === 'profile' ? 'visible' : 'hidden'}>
          <UserProfile />
        </Activity>

        <Activity mode={currentTab === 'settings' ? 'visible' : 'hidden'}>
          <UserSettings />
        </Activity>
      </main>
    </div>
  );
}

The mode Prop

The magic happens in the mode prop. It accepts two string values:

  1. "visible": The component acts like a normal React component. It is mounted, rendered to the DOM, and fully interactive.
  2. "hidden": The component is visually removed. However, its React state is preserved.

What happens when you switch to "hidden"?

If you were to inspect the DOM in your browser's DevTools when mode switches to "hidden", you might be surprised. Depending on the specific implementation details of the release (which can vary slightly as the API matures), React typically removes the child content from the DOM entirely or marks it with a special internal attribute ensuring it takes up zero layout space.

Crucially, React stops firing layout effects (useLayoutEffect) for the hidden component, and it lowers the priority of any updates happening inside that hidden tree.

This is an important distinction: Hidden does not mean frozen. If a context value changes that the hidden component depends on, React will eventually update that hidden component's state, but it will do so at a lower priority than the visible UI. This ensures that background tabs stay up-to-date without janking up the scrolling animation the user is currently looking at.

Core Concepts: How <Activity> Works Under the Hood

To truly master the React 19.2 Activity Component, you need to shift your mental model a bit. It’s helpful to think of React rendering in two layers: the Fiber Tree (virtual) and the Host Tree (DOM).

In a traditional unmount:

  1. React deletes the Fiber nodes.
  2. React deletes the DOM nodes.
  3. State is lost.

In a traditional display: none:

  1. Fiber nodes stay.
  2. DOM nodes stay (just styled hidden).
  3. State is kept, but performance suffers.

With <Activity mode="hidden">:

  1. Fiber Nodes Stay: React keeps the virtual representation alive in memory. This is why useState and useReducer hook values are preserved. The component instance is not destroyed.
  2. Host Nodes Detached: React effectively "deactivates" the DOM. In many implementations, this means the DOM nodes are physically removed from the parent container but held in memory, or hidden in a way that completely removes them from the browser's layout and paint calculations.
  3. Effects Are Put on Hold: This is one of the coolest parts. When an Activity goes hidden, React treats it as if it has "unmounted" for the purpose of layout effects, but not for state.

The "Deactivated" Lifecycle

You might be wondering about useEffect. Does it fire cleanups?

When an <Activity> becomes hidden:

  • React does fire cleanup functions for effects, similar to an unmount.
  • When it becomes visible again, it re-runs the setup for effects.

Wait, didn't I just say it doesn't unmount?

It doesn't unmount the state, but it does "unmount" the side effects. This is actually a safety mechanism. Imagine an effect that subscribes to mouse movements or window resizing. You don't want a hidden component listening to mouse events and trying to update the DOM in the background. That would waste resources and potentially cause errors.

So, the mental model is: State is persistent; Effects reflect visibility.

This behavior forces us to write better effects. It encourages us to separate our data (state) from our environment (effects). If your state logic is pure, hiding and showing the component is seamless.

Practical Use Case 1: Building Performant, State-Aware Tabs

Let's build something real. We've all built a tab component a hundred times, but let's build the ultimate tab component using React 19.2.

Imagine a "Project Manager" app.

  • Tab A (Board): A Kanban board with drag-and-drop columns.
  • Tab B (Analytics): Heavy charts showing project velocity.
  • Tab C (Settings): A form with user preferences.

If I drag a card from "ToDo" to "Doing" in Tab A, then switch to look at the Analytics in Tab B, and then switch back to A, I expect that card to still be where I dropped it. If I was halfway through typing a tag name, I expect that text to be there.

Here is how we achieve that with <Activity>.

import { useState, Activity, useId } from 'react';
import KanbanBoard from './KanbanBoard';
import AnalyticsCharts from './AnalyticsCharts';
import ProjectSettings from './ProjectSettings';

const TabButton = ({ isActive, onClick, children }) => (
  <button 
    onClick={onClick}
    style={{ 
      fontWeight: isActive ? 'bold' : 'normal',
      borderBottom: isActive ? '2px solid blue' : 'none',
      padding: '10px 20px',
      background: 'transparent',
      cursor: 'pointer'
    }}
  >
    {children}
  </button>
);

export default function ProjectManager() {
  const [activeTab, setActiveTab] = useState('board');
  
  return (
    <div className="project-manager">
      <header style={{ borderBottom: '1px solid #ccc', marginBottom: '20px' }}>
        <TabButton 
          isActive={activeTab === 'board'} 
          onClick={() => setActiveTab('board')}
        >
          Board
        </TabButton>
        <TabButton 
          isActive={activeTab === 'analytics'} 
          onClick={() => setActiveTab('analytics')}
        >
          Analytics
        </TabButton>
        <TabButton 
          isActive={activeTab === 'settings'} 
          onClick={() => setActiveTab('settings')}
        >
          Settings
        </TabButton>
      </header>

      <div className="tab-content">
        {/* 
           The Kanban Board is heavy. We wrap it in Activity to 
           preserve the drag-and-drop state without keeping the DOM heavy.
        */}
        <Activity mode={activeTab === 'board' ? 'visible' : 'hidden'}>
          <KanbanBoard projectId="proj_123" />
        </Activity>

        {/* 
           Charts are expensive to render. We keep them alive so 
           switching back doesn't trigger a full re-calculation/animation.
        */}
        <Activity mode={activeTab === 'analytics' ? 'visible' : 'hidden'}>
          <AnalyticsCharts projectId="proj_123" />
        </Activity>

        {/* 
           Settings forms are critical for state preservation.
        */}
        <Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
          <ProjectSettings projectId="proj_123" />
        </Activity>
      </div>
    </div>
  );
}

Why this wins

In the code above, KanbanBoard might have complex local state: const [columns, setColumns] = useState(...). If we used standard conditional rendering, switching to 'analytics' would wipe that columns state. The user would return to the board and see the default state, losing their changes unless we synced every single move to a global store or backend immediately.

With <Activity>, we get the persistence of a global store with the simplicity of local state. The code is clean. There are no complex context providers wrapping everything just to save a string of text.

Practical Use Case 2: Optimizing Virtualized Lists

This is where things get really interesting. If you've worked with large datasets, you’ve likely used "virtualization" (or "windowing")—rendering only the items currently visible in the viewport. Libraries like react-window or tanstack-virtual are the industry standard for this.

However, a common complaint with virtualization is that if you scroll quickly, you often see blank white space before the new rows render. Or, if you scroll down and then quickly scroll back up, the browser has to re-paint the rows you just saw because they were destroyed to save memory.

We can use <Activity> to create a "buffer" of fully rendered but hidden items.

Imagine a virtual list where the items slightly off-screen aren't destroyed, but just moved to mode="hidden".

// Conceptual example of an Activity-enhanced list item
function VirtualItem({ children, isVisible }) {
  return (
    <Activity mode={isVisible ? 'visible' : 'hidden'}>
      <div className="list-item">
        {children}
      </div>
    </Activity>
  );
}

Note: In practice, integrating this with a library like react-window requires custom implementation of the row renderers, but the concept stands.

By using <Activity> for rows that have just scrolled out of view, we keep their state (e.g., if a user expanded a row details panel) and their React fiber structure. If the user scrolls back up, the "re-mount" is nearly instant because React doesn't have to reconstruct the fiber tree; it just flips the switch back to visible.

This technique reduces the CPU overhead of rapid scrolling because we aren't constantly creating and destroying component instances—we are just toggling their visibility mode.

Comparing <Activity> to Traditional CSS and State Methods

It’s important to visualize where <Activity> sits in our toolbox. It's not a silver bullet for every situation, but it dominates the middle ground.

Feature Conditional Rendering ({cond && <Comp />}) CSS Hiding (display: none) React Activity (<Activity />)
DOM Footprint Zero (Best) High (Worst) Low (Near Zero)
Memory Usage Low High Medium (Retains Fiber)
State Preservation No (State Lost) Yes Yes
Mounting Cost High (Full Re-mount) Zero Low (Wake up)
Background Updates Impossible High Priority (Slows UI) Low Priority (Good)

The "Goldilocks" Zone

  • Conditional Rendering is still great for things you truly don't need anymore. A modal that closed? Destroy it. A tooltip that vanished? Destroy it.
  • CSS Hiding is useful for very simple elements that need to toggle visibility instantly, like a dropdown menu that is just simple HTML.
  • Activity is for "heavy" sections of UI that the user navigates away from but is likely to return to. Tabs, multi-step forms, master-detail views.

Advanced Patterns: Integrating with Suspense and Hooks

The React 19.2 Activity Component doesn't live in a vacuum. It plays nicely with other modern React features, specifically Suspense.

Activity + Suspense

Here is a scenario: You have a hidden tab that fetches data. What happens if the data fetch suspends?

Normally, if a component suspends, it triggers the nearest <Suspense> fallback. If you have a hidden Activity that suspends, you might worry that it will trigger a fallback spinner that replaces your visible content.

React 19.2 handles this intelligently. If a component inside a hidden Activity suspends, React does not show the fallback in the visible viewport. It waits. The hidden tree waits in the background for the data to resolve.

This effectively gives us background preloading for free.

<Activity mode={isTabActive ? 'visible' : 'hidden'}>
  <Suspense fallback={<Spinner />}>
    {/* 
      If this component initiates a fetch and suspends while hidden,
      the Spinner won't disrupt the current visible UI.
      It loads quietly in the background.
    */}
    <HeavyDataComponent />
  </Suspense>
</Activity>

The useActivity Hook (Hypothetical/Future)

While the component API is the primary way to interact with this feature, patterns are emerging where components might want to know if they are active or not.

Currently, standard effects run on cleanup when hidden. But what if you want to keep a WebSocket connection open only when visible, but keep the messages in state when hidden?

You would use the standard useEffect cleanup pattern:

useEffect(() => {
  const socket = connect();
  socket.on('message', msg => saveToState(msg));

  return () => {
    // This runs when the component unmounts OR becomes hidden
    socket.disconnect();
  };
}, []);

If you want the connection to persist even when hidden, you would need to move that logic up to a parent or context that isn't inside the <Activity>. This enforces a clean separation: Visual components shouldn't manage persistent background processes.

Performance Gains: Benchmarks and Best Practices

So, is it actually faster?

In early benchmarks of React 19.2 applications, replacing display: none implementations with <Activity> resulted in significant improvements in Interaction to Next Paint (INP).

Why does it improve INP?

When you interact with a page (click a button), the browser has to recalculate styles and layout. If you have a massive hidden DOM tree (via display: none), the browser still has to check those nodes during style recalculations. It adds friction to every single frame.

By using <Activity>, you remove those nodes from the browser's immediate concern. The browser's "Layout" and "Paint" steps become much cheaper because the DOM tree is physically smaller.

Best Practices for Maximum Performance

  1. Don't Wrap Everything: Do not wrap every single list item or button in <Activity>. It has a memory overhead (preserving fiber nodes). Use it for coarse-grained sections of your UI (screens, tabs, large widgets).
  2. Watch Memory Usage: Since you are intentionally not destroying objects, memory usage will be higher than unmounting. If your user is on a low-end device and opens 50 tabs, you might still want to implement a "Least Recently Used" (LRU) eviction strategy to fully unmount tabs that haven't been touched in an hour.
  3. Key Prop Still Matters: Remember that <Activity> preserves state based on its position in the tree. If you dynamically generate Activities in a loop, ensure you use stable key props, or React might confuse which state belongs to which hidden activity.

Conclusion: The Future of Component Rendering in React

The React 19.2 Activity Component is more than just a new tag to add to your JSX. It represents a maturation of the Single Page Application model.

For a decade, we have treated the browser DOM as a volatile place where things are either "here" or "gone." With <Activity>, we are moving towards a model that more closely resembles native mobile operating systems—where apps and views have a lifecycle state of "backgrounded." They aren't dead, they're just waiting.

This allows us to build web applications that respect the user's input. We stop throwing away their work just because they clicked a navigation link. We stop slowing down their experience just because we want to save that work.

As you adopt React 19.2, look at your current codebase. Find those display: none hacks. Find those complex Redux stores that only exist to cache form data for a tab switcher. Delete them. Replace them with <Activity>.

Your code will be cleaner, your app will be faster, and your users—though they might not know why—will feel that your application just "works" better. And in the end, isn't that the whole point?


Frequently Asked Questions

Q: Does <Activity> work with Server Components? A: Yes, but with nuance. Since Server Components render on the server, <Activity> is primarily a client-side concern for managing the browser DOM and client state. You can wrap Server Component output inside a Client Component that uses <Activity>, preserving the rendered UI on the client.

Q: Is <Activity> the same as Vue's <KeepAlive>? A: Conceptually, yes. They solve the exact same problem. However, React's implementation is integrated deeply with the Concurrent Renderer, allowing for features like prioritized updates and suspense integration that are specific to React's architecture.

Q: Can I use <Activity> to animate transitions between tabs? A: <Activity> itself doesn't provide animations, but it makes them easier. Since the "leaving" tab is still mounted (just hidden), you can coordinate animations more easily than if the component was unmounting immediately. However, for complex exit animations, you might still look to libraries like Framer Motion, which may add specific support for Activity modes in the future.

Q: What happens to useLayoutEffect inside a hidden Activity? A: useLayoutEffect (and useEffect) cleanup functions run when the component goes hidden. This prevents hidden components from trying to measure DOM nodes that aren't there or don't have dimensions. When the component becomes visible again, the effects re-run.

Related Articles