You switch tabs. The video you were watching keeps playing in the background. Or the draft in a <textarea> vanishes because you unmounted the panel. Both symptoms come from the same decision: how you hide a tab. And in React, that decision has a third option that changes the contract.
The usual choices are conditional mounting ({activeTab === 'video' && <Video />}) or keeping everything mounted and toggling display: none. The first destroys React state and DOM state. The second preserves both, but it also leaves every Effect and every DOM side effect running. React’s <Activity> boundary is designed to split that difference: it hides children with display: none, destroys their Effects, and keeps their React and DOM state for restoration.
This article is about choosing the hiding mechanism by the state you need to preserve and the side effects you need to stop. It is not a styling preference. It is a state-preservation contract, and getting it wrong shows up as background audio, leaked subscriptions, and drafts that disappear.
Stability note: React’s own references currently document <Activity> as experimental in some locations, exported as unstable_Activity and not available in a stable React version. The primary English react.dev reference does not state stable availability in the retrieved text. Before adopting Activity in production, confirm the export and stability status against the React version pinned in your package.json and the primary react.dev reference for that version. Do not assume the API surface is identical across 19.x minors without checking.
What each hiding strategy actually does
Before comparing, pin the behavior to the documentation. React’s <Activity> reference states that when an Activity boundary is hidden, React visually hides its children using the display: "none" CSS property and destroys their Effects, cleaning up active subscriptions. When the boundary becomes visible again, React reveals the children with their previous state restored and re-creates their Effects. While hidden, children still re-render in response to new props, at a lower priority than the rest of the content. (React docs: <Activity>)
That gives three distinct behaviors:
- Conditional mount/unmount. React state is destroyed. DOM state is destroyed. Effects run cleanup and are removed. This is the default when you write
{isVisible && <Panel />}.
display: none on a mounted subtree. React state survives. DOM state survives. Effects keep running because React never unmounted the component. DOM side effects persist because the DOM nodes still exist. MDN describes display: none as turning off display so the element has no effect on layout, with all descendants also having their display turned off. (MDN: display)
<Activity mode="hidden">. React state survives. DOM state survives. Effects are destroyed on hide and re-created on show. The DOM is hidden with display: none, but the Effect lifecycle behaves as if the subtree were unmounted.
The key sentence from the React docs is that Activity can be thought of as a mechanism for rendering “background activity”: rather than discarding content that is likely to become visible again, it maintains and restores the UI and internal state while ensuring hidden content has no unwanted side effects. That is the contract.
The video case: display: none keeps the Effect alive
Consider a tabbed interface with two video players. You want each tab to remember its playback position, so you render both players and toggle display: none on the inactive one. The React docs walk through exactly this failure: rendering both videos and hiding the inactive one with display: none causes both videos to play at the same time. (React Activity reference, troubleshooting section)
Why? The play() call lives in an Effect. Because the component is still mounted, the Effect is still active. display: none removes the element from layout, but it does not stop media playback. The browser keeps decoding audio for an element the user cannot see.
Activity solves this by not creating Effects when first rendered as hidden and destroying all Effects when switching from visible to hidden. The docs describe the intended behavior: the cleanup pauses the video, and switching back re-creates the Effect and resumes playback. The same reference notes that because a hidden component’s DOM is not destroyed, any side effects from that DOM will persist even after the component is hidden. That is the precise problem Activity is built to avoid.
If you have ever shipped a tabbed dashboard where a hidden chart kept polling, a hidden map kept firing tile requests, or a hidden video kept playing, this is the mechanism. The component was never unmounted, so its Effects never cleaned up.
The textarea case: unmounting loses DOM state
The opposite failure is conditional mounting. A contact tab contains a <textarea>. The user types a draft, switches to Home, and switches back. The draft is gone. The React docs use this exact example: because the Contact component is fully unmounted, the <textarea> element’s internal DOM state is lost. Wrapping the tabs in Activity boundaries preserves the DOM node and its internal state across tab switches. (React docs: Restoring the DOM of hidden components)
This is the case where display: none was doing real work for you. It kept the DOM alive. The problem is that it also kept every Effect alive. Activity is the targeted fix: it preserves the DOM state you want while destroying the Effects you almost certainly do not want running in the background.
What Activity does not do
Activity is not a drop-in replacement for every hidden tab, and the docs are explicit about the boundaries.
Hidden Activity content is not in the SSR response. The reference states that parts of the UI wrapped in <Activity mode="hidden"> are not included in the SSR response. If a tab’s content must be present in the initial server-rendered HTML, hidden Activity will not provide it. The docs suggest useDeferredValue as an alternative when you need to defer rendering while keeping content in the response. (React Activity reference, SSR caveat)
Pre-rendering only warms data that suspends. A hidden Activity rendered during the initial render still renders its children at lower priority and without mounting their Effects, which lets code or data load ahead of time. But the docs are precise: only data read from a source that activates a Suspense boundary, such as a Promise read with use, is fetched during pre-rendering. Activity does not detect data fetched inside an Effect. If your tab fetches in useEffect, the pre-render benefit does not apply. (React docs: Pre-rendering content)
A hidden Activity that renders only text produces no DOM output. The reference notes that a hidden Activity that just renders text will not render anything rather than rendering hidden text, because there is no corresponding DOM element to apply visibility changes to. If you rely on the hidden subtree existing in the DOM for measurement or third-party scripts, wrap it in an element.
Effects that assume mount-once will run again. Because Activity destroys Effects on hide and re-creates them on show, an Effect that was written as “run once on mount” will run again when the tab becomes visible. The docs recommend <StrictMode> to catch unexpected side effects from this behavior. This is not a bug; it is the contract. If your Effect is not resilient to setup/cleanup cycles, Activity will expose it.
What about layout and paint?
It is worth being precise here, because the topic invites a wrong conclusion. display: none already removes the element from layout. MDN states that display: none turns off display so the element has no effect on layout, and all descendants also have their display turned off. So the layout cost of a hidden tab is not the main problem with display: none in a React tab UI. The main problem is the Effect lifecycle and the DOM side effects that keep running.
Activity also hides with display: none, so it does not change the layout story. What it changes is that the hidden subtree’s Effects are destroyed, and updates to hidden children are deferred to lower priority. The React docs state that while hidden, children still re-render in response to new props, albeit at a lower priority than the rest of the content. That is a scheduling difference, not a layout difference.
If your actual problem is off-screen rendering cost for content that is not a tab, the CSS content-visibility property is the relevant tool. The web.dev article on content-visibility describes it as enabling the user agent to skip an element’s rendering work, including layout and painting, until it is needed, and notes that content-visibility: hidden hides an element while preserving its rendering state, unlike display: none which destroys rendering state. (web.dev: content-visibility) That is a browser rendering optimization, not a React Effect lifecycle mechanism. It does not stop your useEffect subscriptions. Do not treat it as a substitute for Activity in a React tab UI.
A decision table for tab UIs
The right question is not “which is faster?” It is “which state must survive, and which side effects must stop?”
| Strategy |
React state |
DOM state |
Effects / subscriptions |
Use when |
| Conditional mount/unmount |
Destroyed |
Destroyed |
Cleaned up |
You want a fresh start every time, or the tab is expensive and rarely revisited. |
display: none on mounted subtree |
Preserved |
Preserved |
Still running |
Only when you explicitly accept that Effects and DOM side effects continue while hidden. |
<Activity mode="hidden"> |
Preserved |
Preserved |
Destroyed on hide, re-created on show |
You want drafts, scroll positions, and form state to survive, but you want polling, media, and subscriptions to stop. |
Two caveats sit on top of that table. First, if the tab’s content must be in the initial SSR HTML, hidden Activity will not give it to you. Second, if the tab’s data is fetched in an Effect rather than through Suspense, Activity’s pre-rendering benefit does not apply.
How to audit an existing tab UI
You do not need a benchmark to find the candidates. You need to read the Effects inside every subtree that is currently hidden with display: none.
- List every
display: none toggle in the tab container. For each hidden subtree, list its Effects.
- Flag Effects that assume visibility. Media playback, polling intervals, WebSocket subscriptions,
IntersectionObserver and ResizeObserver registrations, analytics timers, and animation loops. These are the ones that keep running while the user cannot see the tab.
- Flag DOM side effects that persist. The React docs call out that because a hidden component’s DOM is not destroyed, side effects from that DOM persist. A playing
<video> is the canonical example.
- Check whether the Effect is resilient to setup/cleanup cycles. Activity will destroy and re-create it. If the cleanup does not fully mirror the setup, the tab will misbehave on the second visit. The
useEffect reference describes the contract: setup runs on commit, cleanup runs before the next setup when dependencies change, and cleanup runs after the component is removed from the DOM. (React docs: useEffect)
- Check SSR requirements before migrating. If the hidden tab’s markup must be in the server response, Activity is the wrong tool for that tab.
This audit is the migration plan. The tabs with visibility-assuming Effects are the ones where Activity changes behavior in a way users will notice, usually for the better.
How to verify the change
Two separate traces answer two separate questions.
React DevTools Profiler answers: did the Effect lifecycle change as intended? Record a session that switches to a tab and back. Confirm that the hidden tab’s Effects were cleaned up on hide and re-created on show. If a subscription is still active while the tab is hidden, the migration did not take effect, or the Effect is living outside the Activity boundary.
Chrome DevTools Performance panel answers: did the main thread work change? The Performance panel records runtime performance and represents main thread activity in a flame chart, with the x-axis as time and the y-axis as the call stack. (Chrome DevTools Performance reference) Record a trace that switches tabs and compare the main-thread activity before and after. If the hidden tab was polling or animating, the trace will show it. If Activity stopped it, the trace will show the gap.
Do not expect Activity to reduce layout cost for the hidden subtree. Both display: none and Activity remove the subtree from layout. The measurable difference is in Effect activity and in the priority of updates to hidden children, not in layout time for the hidden content itself.
Version pinning and status
React 19 was released as stable on December 5, 2024, per the React 19 release post. (React v19 release) The <Activity> reference on react.dev documents the component’s behavior as described above. React’s own references, including es.react.dev, currently document Activity as experimental in some locations, exported as unstable_Activity and unavailable in stable React. The primary English react.dev reference does not state stable availability in the retrieved text. Before adopting Activity in production, confirm the export and stability status against the React version pinned in your package.json and the primary react.dev reference for that version. Do not assume the API surface is identical across 19.x minors without checking.
This article makes no claim about Next.js App Router integration specifics for Activity, because the retrieved primary sources do not cover it. If you are on the App Router, verify server-rendering behavior against your framework version before migrating tabs whose content must appear in the initial HTML.
FAQ
Does display: none stop my useEffect from running?
No. display: none is a CSS change. The component stays mounted, so its Effects stay active. If you need Effects to stop while a tab is hidden, you need to unmount the subtree or wrap it in an Activity boundary.
Does Activity reduce layout work for the hidden tab?
Activity hides children with display: none, which already removes them from layout. The difference Activity makes is in the Effect lifecycle and in deferring updates to hidden children to lower priority. It is not a layout optimization for hidden content.
Can I use Activity for a tab whose content must be server-rendered?
Not if the content is inside <Activity mode="hidden"> during SSR. The React reference states that hidden Activity content is not included in the SSR response. The docs suggest useDeferredValue when you need to defer rendering while keeping content in the response.
Will Activity pre-fetch my tab’s data?
Only if the data is read through a Suspense boundary, such as a Promise read with use. The React docs state that Activity does not detect data fetched inside an Effect. If your tab fetches in useEffect, pre-rendering will not warm it.
Why does my video keep playing when I hide its tab?
Because the component is still mounted and its play() Effect is still active. display: none removes the element from layout but does not stop media playback. The React Activity reference documents this exact failure and shows Activity as the fix, because Activity destroys Effects on hide.
Is content-visibility: hidden a replacement for Activity?
No. content-visibility: hidden preserves browser rendering state for hidden content, but it does not change React’s Effect lifecycle. Your subscriptions and media Effects keep running. Use it for browser rendering optimization, not for React side-effect control.