You hide a settings panel with {isOpen && <Panel />}. The user reopens it two seconds later, and the unsaved toggle they'd flipped is back to its default. The scroll position is gone. The debounced search they'd typed into a nested filter has vanished. Nothing is broken — React unmounted <Panel /> and mounted a brand new one, exactly as that code tells it to. You just didn't mean to ask for that.
Before React 19.2, there was no built-in way to say "hide this, but keep it exactly as it was." CSS (display: none while the component stays mounted) keeps the state, but its effects keep running in the background — timers ticking, subscriptions open, polling a screen nobody can see. Unmounting runs cleanup correctly but throws the state away. There was no option between "alive and visible" and "gone."
React 19.2 (released October 2025) added <Activity> to close that gap: a built-in way to hide a subtree while keeping its state intact. React 19.3 — released September 2026 and patched to the current npm latest, 19.3.1, in October — shipped several bug fixes for it, including one around useSyncExternalStore updates while hidden, that are worth understanding on their own, because they show exactly where the "hidden but alive" model gets subtle. This is episode eight of React Deep Dive, and it picks up where episode one, on re-render vs. remount, left off: <Activity> is neither of those two things. It's a third state, with its own rules, and treating it like either of the other two is exactly how the 19.3 bugs happened.
What you'll learn
By the end of this article you'll be able to:
- Explain precisely what
<Activity mode="hidden">preserves and what it tears down — state, effects, refs, and renders are four separate questions, not one - Replace a
{condition && <Panel/>}pattern with<Activity>when hiding, not destroying, is what you actually want - Predict when a hidden subtree still re-renders (and why that's not a bug)
- Avoid the two traps that the 19.3 patch notes had to fix for the React team itself
- Use
<StrictMode>to catch the effect-cleanup bugs that<Activity>surfaces
Who this is for
You're comfortable with function components, useState, and useEffect, and you've been bitten at least once by state resetting when you only meant to hide something. You don't need to have used <Activity> before.
Table of contents
- The problem: hiding isn't unmounting, and unmounting isn't hiding
- The mental model
- The walkthrough
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
The problem: hiding isn't unmounting, and unmounting isn't hiding
Say you're building a dashboard with three report tabs. Each tab has its own view: a filter form, a live-updating counter from a useEffect-driven interval (think "records processed," polled every second), and a draft note the user is typing. Switch tabs, and here's the naive version:
function Dashboard({ activeTab }) {
return (
<>
{activeTab === "sales" && <ReportPanel label="Sales" />}
{activeTab === "ops" && <ReportPanel label="Ops" />}
{activeTab === "support" && <ReportPanel label="Support" />}
</>
);
}
This works, in the sense that it compiles and nothing throws. But switch from "Sales" to "Ops" and back, and the Sales ReportPanel is a completely new instance. Its counter restarts at zero. Its draft note is empty. Its useEffect cleanup ran when you left, and a fresh mount effect ran when you came back — including any expensive setup it does on mount. You wanted a tab switch; React gave you a demolition and a rebuild.
The instinctive fix is to keep everything mounted and hide the inactive panels with CSS:
function Dashboard({ activeTab }) {
return (
<>
<div style={{ display: activeTab === "sales" ? "block" : "none" }}>
<ReportPanel label="Sales" />
</div>
<div style={{ display: activeTab === "ops" ? "block" : "none" }}>
<ReportPanel label="Ops" />
</div>
<div style={{ display: activeTab === "support" ? "block" : "none" }}>
<ReportPanel label="Support" />
</div>
</>
);
}
This keeps the state, but now you've traded one bug for another: the Sales panel's interval keeps polling, its effects keep subscribing, and its hidden DOM keeps costing you layout and memory for every tab the user isn't looking at. You've solved the state problem by un-solving the cleanup problem.
<Activity> is React's answer to "I want both": state survives, but effects still tear down and rebuild the way they would for a component you actually cared about keeping correct.
The mental model
The mental model: Render, remount, and <Activity mode="hidden"> are three different operations, and they disagree on three separate things — state, effects, and rendering — not just one axis you can call "aliveness."
| Stays mounted (same instance) | <Activity mode="hidden"> |
Unmount (remove from the tree) | |
|---|---|---|---|
State (useState, useReducer) |
Preserved | Preserved | Destroyed |
Effects (useEffect) |
Keep running | Torn down on hide, re-created on show | Torn down, never re-created (new instance builds fresh ones) |
| Rendering on prop/context change | Happens normally | Still happens, at lower priority | Doesn't happen — there's nothing there |
| DOM | Visible | Hidden (display: none), node kept |
Removed |
| Refs | Point at the live node | Still point at the (now hidden) node | Become null
|
Read the middle column again: hidden is not the same as paused, and it is not the same as destroyed. A component inside a hidden <Activity> keeps its state, loses its effects, and — this is the part people get wrong — keeps rendering when something above it changes, just at a priority React considers less urgent than whatever's actually on screen. That last row is the one the 19.3 patch notes had to fix bugs around, because it's the one that breaks the intuition "hidden means nothing happens here."
Here's the shape in code, next to the two things it's between:
import { Activity, useState } from "react";
function Dashboard({ activeTab }) {
return (
<>
<Activity mode={activeTab === "sales" ? "visible" : "hidden"}>
<ReportPanel label="Sales" />
</Activity>
<Activity mode={activeTab === "ops" ? "visible" : "hidden"}>
<ReportPanel label="Ops" />
</Activity>
<Activity mode={activeTab === "support" ? "visible" : "hidden"}>
<ReportPanel label="Support" />
</Activity>
</>
);
}
Switch tabs now, and the inactive ReportPanels stay mounted as far as state is concerned (the draft note survives), but their effects clean up (the polling interval stops) the moment they go hidden, and start fresh again the moment they're shown. No wasted polling, no lost draft.
Historical aside, labelled: before 19.2 stabilized this, an experimental build exposed the same idea as unstable_Activity. If you see import { unstable_Activity as Activity } from "react" in an older blog post or a pinned pre-19.2 dependency, that's the pre-stable name — don't write it in new code. The stable export is just Activity, no prefix.
The walkthrough
Step 1 — the smallest correct example
import { Activity, useState } from "react";
function Sidebar({ isOpen }) {
const [filter, setFilter] = useState("");
return (
<Activity mode={isOpen ? "visible" : "hidden"}>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
</Activity>
);
}
Key concept: mode is the entire API surface for visibility — "visible" or "hidden" (it defaults to "visible" if you omit it). There's no mode="unmounted"; if you want a true unmount, don't render the <Activity> at all. <Activity> only ever answers "show or hide," never "destroy."
Step 2 — proving the effect actually tears down
function ReportPanel({ label }) {
const [count, setCount] = useState(0);
const [draft, setDraft] = useState("");
useEffect(() => {
const id = setInterval(() => setCount((c) => c + 1), 1000);
return () => clearInterval(id); // runs when Activity hides this subtree
}, []);
return (
<div>
<h3>{label}: {count}</h3>
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
</div>
);
}
Hide this panel's <Activity> and the interval's cleanup runs immediately — count stops climbing. Show it again, and the effect's setup function runs again, starting a new interval. count and draft were never touched by any of this; they're still sitting in React's fiber for this component, exactly where you left them. The playground below is this exact component, running the real reconciler, so you can watch the counter freeze and the draft survive in real time.
Step 3 — the row people miss: hidden subtrees still render
This is the one the mental model table flags and most tutorials skip. A hidden <Activity> doesn't freeze its children — it keeps re-rendering them when their props or context change, just scheduled at a lower priority than the visible UI. Why would React bother? Two real reasons, both from the official docs:
-
Pre-rendering. A
<Activity mode="hidden">on initial render still renders its children (without mounting their effects), which lets data-fetching components start loading before the user ever opens that tab — so when they do, it's instant instead of a fresh Suspense fallback. - Keeping derived UI correct. If a hidden panel's rendered output depends on a prop from its parent (say, a shared "last synced at" timestamp), it should show the right value the instant it's revealed, not a stale one from the moment it was hidden.
The playground's "Bump shared prop" button demonstrates this directly: it changes a value passed into every ReportPanel, visible or hidden, and the hidden panel's render count still climbs — while its effect-mount count does not. Those two counters moving independently is the mental model, observed instead of described.
Step 4 — what the 19.3 bug fixes actually tell you
React 19.3's changelog lists several <Activity> fixes: issues with useSyncExternalStore updates while hidden, portal visibility, metadata hoisting, and error handling. The pattern across them is instructive: every one is a place where "still rendering, but hidden, with effects torn down" created a seam that a feature built on a simpler always-mounted/never-mounted assumption fell through. useSyncExternalStore subscribes to an external store through an effect-like mechanism — exactly what <Activity> tears down on hide — so an update arriving while hidden needs special handling to avoid being silently lost or double-applied on reveal. If you subscribe to anything external (WebSockets, browser APIs, a state manager outside React) inside a component that might live under an <Activity>, assume the subscription is gone while hidden and gets rebuilt from current state when shown.
Edge cases and gotchas
-
Text-only children render nothing when hidden. Hiding works by applying
display: "none"to a wrapper — bare text has no element to attach that to, so it just doesn't render. Wrap bare text in an element if it might end up inside an<Activity>. -
Side effects outside
useEffectkeep running. A<video>,<audio>, or<iframe>isn't paused by hiding — its DOM node is still alive, still doing whatever it was doing. Pause it explicitly, in auseLayoutEffectcleanup tied to the visual hide. - Refs still point at hidden DOM. Unmounting nulls a ref; hiding doesn't — the node still exists, just invisible. Code that treats "ref is set" as "user can see it" will be wrong.
-
StrictMode is how you catch the bugs this exposes. Effects now mount and unmount far more often than a typical lifetime suggests — every hide/show cycle, not just mount/unmount. Any effect that assumed its cleanup never really had to undo everything gets exposed fast. Develop with
<StrictMode>so this surfaces before production does. -
Interaction with
<ViewTransition>. If an<Activity>reveal happens insidestartTransition, and the tree uses<ViewTransition>(also stable in 19.3), showing it triggers that boundary'senteranimation and hiding triggersexit— the two features compose by design.
Best practices
-
Reach for
<Activity>when state is expensive to rebuild and the hide is temporary — tabs, a collapsible sidebar, a wizard step, a modal reopened with the same draft. The signal: the user will plausibly come back to this exact state. - Don't make it a default replacement for conditional rendering. If the user is done with something for good (a dismissed notification, a finished onboarding step), unmounting is correct and cheaper — nothing left to render at low priority, nothing to reconcile later.
- Don't treat it as a pause button for real-world side effects (payments in flight, a write to a server). Hiding a subtree doesn't cancel an in-flight promise; it only affects effect lifecycle and state.
- If a hidden subtree subscribes to anything external, write its effect assuming the subscription is gone while hidden — resync from current state on the next mount rather than assuming buffered delivery.
-
Keep
modeauseState/prop-driven value, not something you mutate imperatively —<Activity>reacts to re-renders like anything else in React.
FAQ
Is <Activity> the same thing as display: none?
No. It uses display: none for the visual hiding, but that's one of four things it does — it also tears down effects (plain CSS never does), preserves state across the hide (an unmount never does), and keeps rendering hidden children at lower priority (a component you've stopped rendering can't do at all).
Does <Activity> replace key-based remounting?
No — opposite problems. key forces React to treat something as a new instance, which is right when you want fresh state. <Activity> keeps the same instance alive while hidden. Want a reset? Use key. Want to preserve exactly what's there? Use <Activity>.
Will <Activity> fetch data for a hidden tab before the user opens it?
Under specific conditions: if the hidden children read data through something Suspense-activating — a promise read with use() — a hidden <Activity> can pre-render and start that fetch early. Data fetched inside a useEffect is not: effects don't run while hidden, so effect-based fetching only starts once the subtree becomes visible.
Does hiding with <Activity> cost anything if I never show the content again?
Some — the hidden subtree still occupies a spot in the fiber tree and can still re-render at low priority. For content the user is realistically done with, unmount it; reach for <Activity> specifically because you expect a return visit.
Can I nest <Activity> components?
Yes — visibility composes as expected. A visible <Activity> inside a hidden one is still effectively hidden, because its ancestor is hidden.
Cheat sheet
import { Activity, useState, useEffect } from "react";
// 1. Import — stable since React 19.2, no prefix.
// import { unstable_Activity as Activity } from "react"; // ❌ pre-19.2 only
// 2. Usage — mode is the whole API.
<Activity mode={isVisible ? "visible" : "hidden"}>
<ExpensiveSubtree />
</Activity>
// 3. What survives hiding vs. what doesn't:
// state (useState/useReducer) → preserved
// effects (useEffect) → torn down on hide, re-created on show
// rendering on prop/context change → still happens, lower priority
// DOM node → kept, display: none
// refs → still point at the (hidden) node
// 4. Pick the right tool:
// "destroy it for good" → don't render it (plain conditional)
// "reset it on purpose" → key={newId}
// "hide it, keep it alive" → <Activity mode="hidden">
// 5. Gotchas:
// - bare text child while hidden renders nothing (no element to hide)
// - <video>/<audio>/<iframe> keep running while hidden — pause explicitly
// in a useLayoutEffect cleanup if that matters
// - external subscriptions (useSyncExternalStore, sockets) are torn down
// on hide — resync on remount, don't assume buffered delivery
// - develop with <StrictMode> to catch effects that assumed they'd only
// ever run once
Key takeaways
-
<Activity mode="hidden">is a third state, not a point on the line between "mounted" and "unmounted": state is preserved, effects are torn down and rebuilt, and rendering on prop change still happens — just at lower priority. - It solves the specific problem of keeping expensive-to-rebuild state alive across a hide/show cycle without paying the cost of a fully-live, always-rendering component.
- The 19.3 bug fixes for
useSyncExternalStore, portals, and metadata under<Activity>all trace back to the same seam: features built assuming "mounted" or "unmounted" as the only two states had to be taught about the third one. - Reach for it when the user will plausibly come back; reach for
keywhen you want a clean reset; reach for plain conditional rendering when the content is genuinely done.
That chat-draft bug from episode one and the dashboard tab bug here are mirror images of each other — one was an accidental remount nobody wanted, this one is an accidental unmount nobody wanted. Same underlying lesson both times: React only does what your JSX actually asks for, never what you meant. Have you reached for <Activity> yet, or are you still hiding things with CSS and hoping the effects behave? Tell me what you're using it for in the comments.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
📚 Read next
- React 19.3 ViewTransition: Animate State Without Losing It
- React Derived State: Why That useState Is Probably a Bug
- React Re-render vs Remount: What Actually Triggers Each
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)