<Activity>

<Activity> lets you hide and reveal part of the UI while preserving its state.

<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Sidebar />
</Activity>

Reference

<Activity>

Wrap part of the component tree in <Activity> to control whether it is visible:

import { Activity } from 'react';

<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Sidebar />
</Activity>

See more examples below.

Modes

An Activity boundary supports two modes:

  • In visible mode, React renders the children, attaches their refs, and runs the setup functions for their useEffect and useLayoutEffect calls.
  • In hidden mode, React hides the children, detaches their refs, and runs the cleanup functions for their useEffect and useLayoutEffect calls. React preserves their state and renders updates at a lower priority than updates to visible content.

When a hidden Activity boundary becomes visible, React reveals its children with their previous state and runs their Effect setup functions again.

In React DOM, hiding an Activity boundary applies display: none to the nearest DOM elements inside the boundary. React preserves those elements while the boundary remains mounted.

Insertion Effects created with useInsertionEffect remain connected while an Activity boundary is hidden because styles may still be needed by the preserved DOM.

Props

  • children: The UI rendered by the Activity boundary. children can be any React node.
  • optional mode: Either 'visible' or 'hidden'. Defaults to 'visible'. See Modes for the behavior of each value.
  • optional name: A string that identifies the Activity boundary in React Developer Tools.

Caveats

  • Browser behavior associated with preserved DOM nodes can continue while the boundary is hidden. For example, audio and video can continue playing. Use an Effect cleanup function to stop this behavior. See an example below.
  • React omits text-only output while an Activity boundary is hidden because a text node cannot receive display: none. The text appears when the boundary becomes visible.
  • If an Activity boundary is inside <ViewTransition>, changing it from hidden to visible as part of an update started with startTransition activates the enter animation. Changing it from visible to hidden as part of that update activates the exit animation.

Usage

Activity is useful when part of the UI may become hidden and visible again. Unlike conditional rendering, hiding an Activity boundary preserves both React state and the DOM state of its children. Unlike hiding content only with CSS, Activity also cleans up the children’s Effects and deprioritizes their updates while they are hidden.

Use an Activity boundary when hidden content is likely to become visible again, such as a tab the user may revisit or a panel that can prepare data in the background. A hidden boundary retains its state and DOM nodes, so it continues using memory. If the content is unlikely to become visible again, conditionally rendering it may be preferable because unmounting allows React and the browser to release its resources.

Preserving component state while content is hidden

Conditional rendering mounts or unmounts a component as its condition changes:

{isShowingSidebar && <Sidebar />}

Unmounting <Sidebar> destroys its internal state. As a result, its state resets each time it is shown.

To preserve the state between hides, keep <Sidebar> mounted inside an Activity boundary and change the boundary’s mode:

<Activity mode={isShowingSidebar ? 'visible' : 'hidden'}>
<Sidebar />
</Activity>

When the boundary becomes visible again, <Sidebar> resumes with its previous state.

The following example has a sidebar with an expandable Overview section and a button that hides and shows the sidebar. To verify that its state is preserved, expand the Overview section, hide the sidebar, and then show it again. The Overview section remains expanded.

import { Activity, useState } from 'react';
import Sidebar from './Sidebar.js';

export default function App() {
  const [isShowingSidebar, setIsShowingSidebar] = useState(true);

  return (
    <div className="layout">
      <Activity mode={isShowingSidebar ? 'visible' : 'hidden'}>
        <Sidebar />
      </Activity>

      <main>
        <button
          aria-controls="documentation-sidebar"
          aria-expanded={isShowingSidebar}
          onClick={() => setIsShowingSidebar(showing => !showing)}
        >
          {isShowingSidebar ? 'Hide' : 'Show'} sidebar
        </button>
        <h1>Main content</h1>
      </main>
    </div>
  );
}

Changing mode preserves the state of the children. Removing the boundary or changing a child’s type, key, or position can reset its state.


Preserving DOM state while content is hidden

In React DOM, an Activity boundary hides its children without removing their DOM nodes. This preserves state held by the browser in those nodes.

The Contact tab in the following example contains an uncontrolled <textarea>. Enter a draft, switch to the Home tab, and then return to Contact. The draft is lost because conditional rendering removes <Contact> and its <textarea> from the DOM.

import { useState } from 'react';
import Contact from './Contact.js';
import Home from './Home.js';
import TabButton from './TabButton.js';

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

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'contact'}
        onClick={() => setActiveTab('contact')}
      >
        Contact
      </TabButton>
      <hr />
      {activeTab === 'home' && <Home />}
      {activeTab === 'contact' && <Contact />}
    </>
  );
}

To preserve the draft, render both tabs inside Activity boundaries and change their modes. Enter a draft, switch to Home, and then return to Contact. The draft remains because Activity hides <Contact> without removing its DOM nodes.

import { Activity, useState } from 'react';
import Contact from './Contact.js';
import Home from './Home.js';
import TabButton from './TabButton.js';

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

  return (
    <>
      <TabButton
        isActive={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </TabButton>
      <TabButton
        isActive={activeTab === 'contact'}
        onClick={() => setActiveTab('contact')}
      >
        Contact
      </TabButton>
      <hr />
      <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
        <Home />
      </Activity>
      <Activity mode={activeTab === 'contact' ? 'visible' : 'hidden'}>
        <Contact />
      </Activity>
    </>
  );
}

Activity also preserves other browser-managed state, such as scroll position and media playback position. The following examples use the same video player to compare removing and hiding a <video> element. Play the video, switch to the Home tab, return to the Video tab, and then select Play.

The difference between removing and hiding content

Example 1 of 2:
Removing the video resets its playback position

The following example conditionally renders the active tab. Play the video, switch to Home, return to Video, and then select Play. Switching to Home removes the <video> element from the DOM, so playback starts from the beginning.

import { useState } from 'react';
import VideoPlayer from './VideoPlayer.js';

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

  return (
    <>
      <button
        aria-pressed={activeTab === 'home'}
        onClick={() => setActiveTab('home')}
      >
        Home
      </button>
      <button
        aria-pressed={activeTab === 'video'}
        onClick={() => setActiveTab('video')}
      >
        Video
      </button>
      {activeTab === 'home' && <p>Welcome to my profile!</p>}
      {activeTab === 'video' && <VideoPlayer />}
    </>
  );
}

The ref provides access to the <video> DOM node. It does not store the playback position. The useLayoutEffect cleanup pauses the video when the player is removed or its Activity boundary becomes hidden. Activity preserves the DOM node and its playback position, but React detaches the ref while the boundary is hidden and reconnects it when the boundary becomes visible. See Troubleshooting for more about cleaning up browser-managed behavior.


Pre-rendering content that is likely to become visible

You can use an Activity boundary to prepare content before the user sees it. Content inside a hidden boundary renders at a lower priority without running Effects created with useEffect or useLayoutEffect. This lets the content load code and render-time data without delaying updates to visible content:

<Suspense fallback={<Loading />}>
<Activity mode={activeTab === 'posts' ? 'visible' : 'hidden'}>
<Posts />
</Activity>
</Suspense>

If Posts suspends while reading code or data, React continues rendering the rest of the page while the hidden work proceeds.

The Posts tab in the following example reads a cached Promise with use. Select Posts to see the Suspense fallback while the data loads. Because conditional rendering does not mount <Posts> until its tab is active, it cannot begin loading the posts ahead of time.

import { Suspense, useState } from 'react';
import Home from './Home.js';
import Posts from './Posts.js';

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

  return (
    <>
      <div aria-label="Profile sections" role="group">
        <button
          aria-pressed={activeTab === 'home'}
          onClick={() => setActiveTab('home')}
        >
          Home
        </button>
        <button
          aria-pressed={activeTab === 'posts'}
          onClick={() => setActiveTab('posts')}
        >
          Posts
        </button>
      </div>

      <Suspense fallback={<h1>Loading posts...</h1>}>
        {activeTab === 'home' && <Home />}
        {activeTab === 'posts' && <Posts />}
      </Suspense>
    </>
  );
}

To start loading the posts before the user selects the tab, render both tabs inside Activity boundaries. React begins rendering <Posts> while its boundary is hidden. Wait briefly before selecting Posts. If the hidden render has finished, the list appears immediately.

import { Activity, Suspense, useState } from 'react';
import Home from './Home.js';
import Posts from './Posts.js';

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

  return (
    <>
      <div aria-label="Profile sections" role="group">
        <button
          aria-pressed={activeTab === 'home'}
          onClick={() => setActiveTab('home')}
        >
          Home
        </button>
        <button
          aria-pressed={activeTab === 'posts'}
          onClick={() => setActiveTab('posts')}
        >
          Posts
        </button>
      </div>

      <Suspense fallback={<h1>Loading posts...</h1>}>
        <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
          <Home />
        </Activity>
        <Activity mode={activeTab === 'posts' ? 'visible' : 'hidden'}>
          <Posts />
        </Activity>
      </Suspense>
    </>
  );
}

If the user selects Posts before the hidden render finishes, the Suspense fallback appears until the data is ready. Pre-rendering can reduce the loading time, but it does not guarantee that the content will always be ready.

Note

Only code and data read during rendering can load during pre-rendering. Activity does not run Effects in hidden content, so data fetched inside an Effect does not load until the boundary becomes visible.

The data source must integrate with Suspense. For example, the component can read a cached Promise with use. See what activates a Suspense boundary.


Improving hydration performance

During hydration, Activity boundaries divide server-rendered pages into units that React can hydrate independently. This is related to the selective hydration behavior of <Suspense>, but it does not require displaying a fallback in the initial UI.

For example, without a boundary React hydrates this page as one unit:

function Page() {
return (
<>
<Post />
<Comments />
</>
);
}

Wrapping Comments in an always-visible Activity boundary creates a separate hydration unit:

function Page() {
return (
<>
<Post />
<Activity>
<Comments />
</Activity>
</>
);
}

The boundary is visible because the mode prop defaults to 'visible'. Its server-rendered HTML remains visible, but React can hydrate it independently from the surrounding page. If the user interacts with that content before React reaches it, React prioritizes hydrating the boundary.

You can also use visible and hidden Activity boundaries for tabbed content:

function Page() {
const [activeTab, setActiveTab] = useState('home');

return (
<>
<button onClick={() => setActiveTab('home')}>
Home
</button>
<button onClick={() => setActiveTab('video')}>
Video
</button>

<Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
<Home />
</Activity>
<Activity mode={activeTab === 'video' ? 'visible' : 'hidden'}>
<Video />
</Activity>
</>
);
}

React does not include initially hidden <Activity> content in server-rendered HTML. On the client, React hydrates the visible content first and renders the hidden content later at a lower priority. Initially visible boundaries are included in the server-rendered HTML and can be hydrated independently. This allows the visible tab and the controls around it to become interactive without waiting for React to render the initially hidden tab.


Troubleshooting

My hidden component keeps playing audio or video

<Activity> hides DOM nodes without removing them. Browser-managed behavior from elements such as <video>, <audio>, and <iframe> can therefore continue while the boundary is hidden.

Unmounting a <video> element stops playback because the browser removes the DOM node. Hiding it with Activity preserves the node, so playback continues unless the component pauses it explicitly.

Add the corresponding cleanup to an Effect. For behavior that must stop at the same time React hides the boundary, use useLayoutEffect:

import { useLayoutEffect, useRef } from 'react';

function VideoPlayer({ src }) {
const ref = useRef(null);

useLayoutEffect(() => {
const video = ref.current;

return () => {
video.pause();
};
}, []);

return <video ref={ref} controls src={src} />;
}

React runs the cleanup when an enclosing Activity boundary becomes hidden. Because the <video> node remains in the DOM, its playback position is preserved for when the boundary becomes visible again.

The useLayoutEffect cleanup runs as part of hiding the UI. A cleanup from useEffect can run later if, for example, a Suspense boundary suspends or a View Transition is in progress.

The video comparison includes this cleanup while demonstrating that Activity preserves the <video> element’s playback position.


My hidden components have Effects that are not running

React runs cleanup functions for Effects created with useEffect and useLayoutEffect when an Activity boundary becomes hidden. It runs their setup functions again when the boundary becomes visible.

An Effect inside a hidden Activity boundary cannot remain active. Move ongoing work that must continue while the UI is hidden to a component outside the boundary, or keep the boundary visible.

If an Effect controls an external system, return a cleanup function so hiding the boundary disconnects from that system:

useEffect(() => {
const connection = createConnection();
connection.connect();

return () => {
connection.disconnect();
};
}, []);

Use <StrictMode> to find Effects that do not clean up correctly. Strict Mode performs an additional setup and cleanup cycle in development.