<Activity>
<Activity>
lets you hide and show its children without resetting their state.
<Activity mode="hidden">
<Page />
</Activity>
Reference
<Activity>
Props
children
: The UI you intend to show and hide.- optional
mode
: Either “visible” or “hidden”. Defaults to “visible”. When “hidden”, updates to the children are deferred to lower priority. The component will not create Effects until the Activity is switched to “visible”. If a “visible” Activity switches to “hidden”, the Effects will be destroyed.
Caveats
- While hidden, the
children
of<Activity>
are visually hidden on the page. <Activity>
will unmount all Effects when switching from “visible” to “hidden” without destroying React or DOM state. This means Effects that are expected to run only once on mount will run again when switching from “hidden” to “visible”. Conceptually, “hidden” Activities are unmounted, but they are not destroyed either. We recommend using<StrictMode>
to catch any unexpected side-effects from this behavior.- When used with
<ViewTransition>
, hidden activities that reveal in a transition will activate an “enter” animation. Visible Activities hidden in a transition will activate an “exit” animation. - Parts of the UI wrapped in
<Activity mode="hidden">
are not included in the SSR response. - Parts of the UI wrapped in
<Activity mode="visible">
will hydrate at a lower priority than other content.
Usage
Hiding content without resetting React state
You can use Activity to hide part of your application without resetting its local React state:
<Activity mode={isShowingSidebar ? "visible" : "hidden"}>
<Sidebar />
</Activity>
When an Activity boundary becomes hidden, React will visually hide its children without destroying any of its local component state.
If the boundary becomes visible again, React will reveal the content with the same state values from before it was hidden.
The following example has a sidebar with an expandable section – press “Overview” to reveal the three subitems below it. The main app area also has a button that hides and shows the sidebar.
Try expanding the Overview section, hiding the sidebar, and then showing the sidebar again:
import { useState } from 'react'; import Sidebar from './Sidebar.js'; export default function App() { const [isShowingSidebar, setIsShowingSidebar] = useState(true); return ( <> {isShowingSidebar && ( <Sidebar /> )} <main> <button onClick={() => setIsShowingSidebar(!isShowingSidebar)}> Toggle sidebar </button> <h1>Main content</h1> </main> </> ); }
The Overview section starts out collapsed again! Because we unmount the sidebar when isShowingSidebar
flips to false
, all its internal state is lost.
This is a perfect use case for Activity. We can preserve the internal state of our sidebar, even when visually hiding it.
Let’s replace the conditional rendering of our sidebar with an Activity boundary:
// Before
{isShowingSidebar && (
<Sidebar />
)}
// After
<Activity mode={isShowingSidebar ? 'visible' : 'hidden'}>
<Sidebar />
</Activity>
and check out the new behavior:
import { unstable_Activity as Activity, useState } from 'react'; import Sidebar from './Sidebar.js'; export default function App() { const [isShowingSidebar, setIsShowingSidebar] = useState(true); return ( <> <Activity mode={isShowingSidebar ? 'visible' : 'hidden'}> <Sidebar /> </Activity> <main> <button onClick={() => setIsShowingSidebar(!isShowingSidebar)}> Toggle sidebar </button> <h1>Main content</h1> </main> </> ); }
Our sidebar’s internal state is now preserved, and we didn’t have to change anything about its implementation.
Hiding content without resetting the DOM
Activity boundaries also preserve their children’s DOM when hidden, making them great for preserving ephemeral state in parts of the UI the user is likely to interact with again.
In this example, the Contact tab has a <textarea>
that lets the user enter a message. If you enter some text, change to the Home tab, then change back to the Contact tab, the draft message is lost:
export default function ContactTab() { return ( <div> <p>Send me a message!</p> <textarea /> <p>You can find me online here:</p> <ul> <li>admin@mysite.com</li> <li>+123456789</li> </ul> </div> ); }
This is because we’re fully unmounting the <ContactTab>
in App.js
. When the Contact tab unmounts, the <textarea>
element’s internal state is lost.
If we switch to using an Activity boundary to show and hide the active tab, we can preserve the state of each tab’s DOM. Try entering text and switching tabs again, and you’ll see the draft message is no longer reset:
import { useState, unstable_Activity as Activity } from 'react'; import TabButton from './TabButton.js'; import HomeTab from './HomeTab.js'; import ContactTab from './ContactTab.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'}> <HomeTab /> </Activity> <Activity mode={activeTab === 'contact' ? 'visible' : 'hidden'}> <ContactTab /> </Activity> </> ); }
Again, the Activity boundary let us preserve the Contact tab’s internal state without changing its implementation.
Preventing hidden content from having unwanted side effects
An Activity boundary hides its content by setting display: none
on its children and cleaning up any of their Effects. So, most well-behaved React components that properly clean up their side effects will already be robust to being hidden by Activity.
But there are some situations where a hidden component behaves differently than an unmounted one. Most notably, since a hidden component’s DOM is not destroyed, any side effects from that DOM will persist, even after the component is hidden.
As an example, consider a <video>
tag. Typically it doesn’t require any cleanup, because even if you’re playing a video, unmounting the tag stops the video and audio from playing in the browser. Try playing the video and then pressing Home in this demo:
import { useState } from 'react'; import TabButton from './TabButton.js'; import HomeTab from './HomeTab.js'; import VideoTab from './VideoTab.js'; export default function App() { const [activeTab, setActiveTab] = useState('video'); return ( <> <TabButton isActive={activeTab === 'home'} onClick={() => setActiveTab('home')} > Home </TabButton> <TabButton isActive={activeTab === 'video'} onClick={() => setActiveTab('video')} > Video </TabButton> <hr /> {activeTab === 'home' && <HomeTab />} {activeTab === 'video' && <VideoTab />} </> ); }
The video stops playing as expected.
Now, let’s say we wanted to preserve the timecode where the user last watched, so that when they tab back to the video, it doesn’t start over from the beginning again.
This is a great use case for Activity!
Let’s update our App.js
component to hide the inactive tab with a hidden Activity boundary instead of unmounting it, and see how the demo behaves this time:
import { useState, unstable_Activity as Activity } from 'react'; import TabButton from './TabButton.js'; import HomeTab from './HomeTab.js'; import VideoTab from './VideoTab.js'; export default function App() { const [activeTab, setActiveTab] = useState('video'); return ( <> <TabButton isActive={activeTab === 'home'} onClick={() => setActiveTab('home')} > Home </TabButton> <TabButton isActive={activeTab === 'video'} onClick={() => setActiveTab('video')} > Video </TabButton> <hr /> <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}> <HomeTab /> </Activity> <Activity mode={activeTab === 'video' ? 'visible' : 'hidden'}> <VideoTab /> </Activity> </> ); }
Whoops! The video and audio continues to play even after it’s been hidden, because the tab’s <video>
element is still in the DOM.
To fix this, we can add an Effect with a cleanup function that pauses the video:
export default function VideoTab() {
const ref = useRef();
useEffect(() => {
const videoRef = ref.current;
return () => {
videoRef.pause()
}
}, []);
return (
<video
ref={ref}
controls
playsInline
src="..."
/>
);
}
Let’s see the new behavior:
import { useState, unstable_Activity as Activity } from 'react'; import TabButton from './TabButton.js'; import HomeTab from './HomeTab.js'; import VideoTab from './VideoTab.js'; export default function App() { const [activeTab, setActiveTab] = useState('video'); return ( <> <TabButton isActive={activeTab === 'home'} onClick={() => setActiveTab('home')} > Home </TabButton> <TabButton isActive={activeTab === 'video'} onClick={() => setActiveTab('video')} > Video </TabButton> <hr /> <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}> <HomeTab /> </Activity> <Activity mode={activeTab === 'video' ? 'visible' : 'hidden'}> <VideoTab /> </Activity> </> ); }
It’s working great! Our cleanup function ensures that the video stops playing if it’s ever hidden by an Activity boundary, and even better, because the <video>
tag is never destroyed, the timecode is preserved, and the video itself doesn’t need to be initialized or downloaded again when the user switches back to watch it.
This is a great example of using Activity to preserve ephemeral DOM state for parts of the UI that become hidden, but the user is likely to interact with again soon.
Our example illustrates that for certain tags like <video>
, unmounting and hiding have different behavior. If a component renders DOM that has a side effect, and you want to prevent that side effect when an Activity boundary hides it, add an Effect with a return function to clean it up.
The most common cases of this will be from the following tags:
<video>
<audio>
<iframe>
Typically, though, most of your React components should already be robust to being hidden by an Activity boundary. And conceptually, you should think of “hidden” Activities as being unmounted.
To eagerly discover other Effects that don’t have proper cleanup, which is important not only for Activity boundaries but for many other behaviors in React, we recommend using <StrictMode>
.
Pre-rendering content that’s likely to become visible
So far, we’ve seen how Activity can hide some content that the user has interacted with, without discarding that content’s ephemeral state.
But Activity boundaries can also be used to prepare content that the user has yet to see for the first time:
<Activity mode="hidden">
<SlowComponent />
</Activity>
When an Activity boundary is hidden during its initial render, its children won’t be visible on the page — but they will still be rendered, albeit at a lower priority than the visible content, and without mounting their Effects.
This pre-rendering allows the children to load any code or data they need ahead of time, so that later, when the Activity boundary becomes visible, the children can be mounted and appear instantly.
Let’s look at an example.
In this demo, the Posts tab loads some data. If you press it, you’ll see a Suspense fallback displayed while the data is being fetched:
import { useState, Suspense } from 'react'; import TabButton from './TabButton.js'; import Home from './Home.js'; import Posts from './Posts.js'; export default function App() { const [activeTab, setActiveTab] = useState('home'); return ( <> <TabButton isActive={activeTab === 'home'} onClick={() => setActiveTab('home')} > Home </TabButton> <TabButton isActive={activeTab === 'posts'} onClick={() => setActiveTab('posts')} > Posts </TabButton> <hr /> <Suspense fallback={<h1>🌀 Loading...</h1>}> {activeTab === 'home' && <Home />} {activeTab === 'posts' && <Posts />} </Suspense> </> ); }
This is because the App
component doesn’t render Posts
until its tab is active.
If we update the App
component to use an Activity boundary to show and hide the active tab, the Posts
component will be pre-rendered when the app first loads, allowing it to fetch its data before it becomes visible.
Try clicking the Posts tab now:
import { useState, Suspense, unstable_Activity as Activity } from 'react'; import TabButton from './TabButton.js'; import Home from './Home.js'; import Posts from './Posts.js'; export default function App() { const [activeTab, setActiveTab] = useState('home'); return ( <> <TabButton isActive={activeTab === 'home'} onClick={() => setActiveTab('home')} > Home </TabButton> <TabButton isActive={activeTab === 'posts'} onClick={() => setActiveTab('posts')} > Posts </TabButton> <hr /> <Suspense fallback={<h1>🌀 Loading...</h1>}> <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}> <Home /> </Activity> <Activity mode={activeTab === 'posts' ? 'visible' : 'hidden'}> <Posts /> </Activity> </Suspense> </> ); }
Posts
was able to prepare itself for an instant render, thanks to the hidden Activity boundary.
Pre-rendering components with hidden Activity boundaries is a powerful way to reduce loading times for parts of the UI that the user is likely to interact with next.
Troubleshooting
Effects don’t mount when an Activity is hidden
When an <Activity>
is “hidden”, all Effects are cleaned up. Conceptually, the children are unmounted, but React saves their state for later. This is a feature of Activity because it means subscriptions won’t be active for hidden parts of the UI, reducing the amount of work needed for hidden content.
If you’re relying on an Effect mounting to clean up a component’s side effects, refactor the Effect to do the work in the returned cleanup function instead.
To eagerly find problematic Effects, we recommend adding <StrictMode>
which will eagerly perform Activity unmounts and mounts to catch any unexpected side-effects.
My hidden Activity is not rendered in SSR
When you use <Activity mode="hidden">
during server-side rendering, the content of the Activity will not be included in the SSR response. This is because the content is not visible on the page and is not needed for the initial render. If you need to include the content in the SSR response, you can use a different approach like useDeferredValue
to defer rendering of the content.