Hiding and pre-rendering with Activity
Hide part of a React page with Activity and keep its state and DOM, stop its effects while hidden, pre-render a tab, and compare it with conditional rendering.
- 01Hide a tab without losing itWrap a tab in
<Activity mode>so a draft, an open section, and the rest of its state and DOM survive while it's hidden. - 02Predict what runs while hiddenExplain why a hidden tab's effects are cleaned up and run again when it's shown, and why it still re-renders, at a lower priority.
- 03Choose how to hidePre-render a tab the reader will open next, and pick between
Activity, conditional rendering, and CSSdisplay: none.
Hide part of the page without losing it
Apps hide things all the time: the tab you aren't on, a closed sidebar, the page behind a dialog. When the reader comes back, they expect it as they left it. <Activity> is a component that comes with React for this. You wrap part of the page in it and give it a mode: "visible" shows its children, "hidden" hides them but keeps them. Here a support app has two tabs. The result is live: write something in the chat box, open Profile, then come back to Chat.
import { Activity, useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> </nav> <Activity mode={tab === "chat" ? "visible" : "hidden"}> <Chat /> </Activity> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> </> );}Your message is still there. Line 1 imports Activity from React. Line 6 keeps which tab is open. Lines 13 to 15 wrap Chat in an Activity boundary whose mode is "visible" while the chat tab is open and "hidden" otherwise, and lines 16 to 18 do the same for Profile. The draft lives in Chat's own state, on line 4 of the Chat.jsx tab.
Both tabs are in the page the whole time. The hidden one is still mounted, still has its state, and still has its DOM elements; React only hides those elements. Activity became a stable part of React in React 19.2, with these two modes.
hidden. Chat isn't removed: it keeps its draft and its DOM, and React hides that DOM with display: none.Closing a laptop's lid doesn't shut it down. The screen goes dark, the network drops, and programs pause, but your open windows stay in memory. Open the lid and you're back in the sentence you were writing, and the laptop reconnects by itself.
- In real life: You close the lid, and the screen goes dark
- In React:
mode="hidden": React hides the tab's elements - In real life: Your open documents and half-written email stay in memory
- In React: The tab keeps its state and its DOM
- In real life: While it sleeps, the laptop drops its Wi-Fi and stops its programs
- In React: The tab's effects are cleaned up: connections close, timers stop
- In real life: You open the lid, and everything is where you left it, back online
- In React:
mode="visible": the tab shows again, and its effects run again
<Activity mode="hidden"> hides its children without unmounting them. Their state and DOM stay, React hides the DOM with display: none, and their effects are cleaned up until mode is "visible" again. A tab that starts hidden is rendered in the background, ready for when it's opened.
Conditional rendering throws the tab away
The usual way to show one tab is conditional rendering with &&. Here is the same app written that way. Write a message, open Profile, and come back:
import { useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> </nav> {tab === "chat" && <Chat />} {tab === "profile" && <Profile />} </> );}The draft is gone, and the counter under it says 0 characters. When you open Profile, line 13 renders false instead of <Chat />, so React unmounts Chat: it removes its elements and throws its state away. Coming back renders a brand-new Chat, whose state starts at useState("") again. As Preserving and resetting state showed, React keeps state only while a component stays at its place in the tree.
Sometimes that's what you want, like a form that should start empty each time it opens. When it isn't, people reach for workarounds: lift the draft up to the parent, or save it somewhere. Activity keeps it where it is, without changing Chat at all.
A hidden tab keeps its state and its DOM
Some of what a reader leaves behind isn't in React state at all. It lives in the DOM elements themselves: the text in a <textarea> without a value prop, whether a <details> section is open, how far a list is scrolled. Because Activity keeps the elements, it keeps those too:
import { Activity, useState } from "react"; function Notes() { return ( <section id="notes"> <details> <summary>Shipping help</summary> <p>Orders ship in 2 days.</p> </details> <textarea placeholder="Your notes" /> </section> );} export default function SupportApp() { const [showNotes, setShowNotes] = useState(true); function logStyle() { console.log(document.getElementById("notes").getAttribute("style")); } return ( <> <button onClick={() => setShowNotes(!showNotes)}>{showNotes ? "Hide" : "Show"} notes</button> <button onClick={logStyle}>Log the style</button> <Activity mode={showNotes ? "visible" : "hidden"}> <Notes /> </Activity> </> );}Lines 6 to 9 are a <details> section, and line 10 a <textarea> with no value and no state: React never knows what you type in it. Open Shipping help, type a note, then press Hide notes and Show notes. The section is still open and your note is still there, because the elements never left the page.
Press Hide notes again, then Log the style. Line 18 logs the style attribute of the section, and the console says display: none !important;. Show the notes and log it again: it's now empty. That one style is all React does to the DOM to hide it. Here is the code that does it in React DOM, from ReactFiberConfigDOM.js:
export function hideInstance(instance: Instance): void {
// TODO: Does this work for all element types? What about MathML? Should we
// pass host context to this method?
instance = instance as any as HTMLElement;
const style = instance.style;
// $FlowFixMe[method-unbinding]
if (typeof style.setProperty === 'function') {
style.setProperty('display', 'none', 'important');
} else {
style.display = 'none';
}
}
export function hideTextInstance(textInstance: TextInstance): void {
textInstance.nodeValue = '';
}In plain words:
- Line 1:
hideInstancegets one DOM element, the top element of something to hide. React calls it for each top element inside the hidden boundary, not for every element. - Lines 2 and 3 are a note from React's team, and line 6 a note for their type checker.
- Lines 7 and 8 set
display: nonewithimportant, so the page's own CSS can't show it again. - Lines 9 and 10 are a fallback for elements whose style has no
setProperty. - Lines 14 to 16: plain text has no style, so
hideTextInstanceempties the text instead. A hidden component that returns only text shows nothing.
When the boundary is visible again, React's unhideInstance, a few lines further down, puts back the element's own display from its style prop, or clears it when the prop doesn't set one.
Effects stop while a tab is hidden
A hidden tab keeps its state, but nothing in it should keep running: no open chat connection, no timer, no subscription. So Activity cleans up a hidden tab's effects. Here the chat tab connects to a room in an effect, in the Chat.jsx tab, and counts the seconds it has been online. Open the console under the result first:
import { Activity, useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); const [room, setRoom] = useState("#general"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> <button onClick={() => setRoom("#orders")}>Move to #orders</button> </nav> <Activity mode={tab === "chat" ? "visible" : "hidden"}> <Chat room={room} /> </Activity> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> </> );}In the Chat.jsx tab, the effect on lines 8 to 15 logs Connect to #general, starts a timer that adds one to seconds every second, and returns a cleanup that logs Disconnect from #general and stops the timer. Line 6 logs every render, so the console line Chat renders for #general counts up with every tick. Try this:
- Wait for
online for 3 s, and type a draft. - Open Profile. The console prints
Disconnect from #general: hiding ran the cleanup. The render count stops growing, because the timer stopped. - Wait a few seconds and open Chat. The console prints
Connect to #generalagain, and the heading shows the same number of seconds as when you left, then counts on. Your draft is there too. The state was kept; only the effect stopped and started again.
You'll also see a Disconnect and a second Connect right when the chat appears, at the start and each time you come back. That's StrictMode in development: it runs a new effect, its cleanup, and the effect again, to check that your cleanup undoes the effect. It does the same when Activity shows a tab again. In production, each line appears once.
Hidden content still renders when its props change. Open Profile, then press Move to #orders (line 13 changes the room prop). The console prints Chat renders for #orders while the chat is hidden, but no Connect: React renders the hidden tab so it's up to date, at a lower priority than anything on screen, and runs its effects only when you open it. Open Chat, and it connects to #orders.
One case needs your help. Some elements do things on their own, outside React: a <video> or <audio> keeps playing, and an <iframe> keeps running its page. Unmounting removed them, which stopped them. Activity keeps them, so a hidden video would keep playing. Give it an effect whose cleanup pauses it:
function VideoTab() {
const videoRef = useRef(null);
useLayoutEffect(() => {
const video = videoRef.current;
return () => video.pause();
}, []);
return <video ref={videoRef} src="/tea-ceremony.mp4" controls />;
}This fragment needs a video file, so it doesn't run here. Lines 4 to 7 are a layout effect whose cleanup, on line 6, pauses the video. Hiding runs that cleanup at once, and the video keeps its place, because the element stays. react.dev suggests a layout effect here because the pause belongs with the moment the tab disappears. Exercise 5 has a runnable case of the same problem.
Render a tab before anyone opens it
A boundary can be hidden from the very first render. Then React still renders its children, at a lower priority than the visible page, and without running their effects. That's pre-rendering: anything the tab needs while rendering, like data read with use or a component's code, starts loading before the reader asks. The orders below come from a pretend server in the data.js tab, which answers after one second. Press Run again, wait a moment, then open Orders:
import { Activity, Suspense, useState } from "react";import Orders from "./Orders.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("profile"); return ( <> <nav> <button onClick={() => setTab("profile")}>Profile</button> <button onClick={() => setTab("orders")}>Orders</button> </nav> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> <Activity mode={tab === "orders" ? "visible" : "hidden"}> <Suspense fallback={<p>Loading orders…</p>}> <Orders /> </Suspense> </Activity> </> );}The orders appear at once. Line 6 opens on Profile, so the boundary on lines 16 to 20 starts hidden. React renders Orders anyway, which calls use(fetchData("/orders")) on line 5 of its tab, so the console prints Load /orders as the page opens. The Suspense boundary on lines 17 to 19 waits for the data out of sight. By the time you click, the orders are ready. Click within the first second and you'll see Loading orders… for what's left of it.
Compare it with the same tabs and conditional rendering:
import { Suspense, useState } from "react";import Orders from "./Orders.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("profile"); return ( <> <nav> <button onClick={() => setTab("profile")}>Profile</button> <button onClick={() => setTab("orders")}>Orders</button> </nav> {tab === "profile" && <Profile />} {tab === "orders" && ( <Suspense fallback={<p>Loading orders…</p>}> <Orders /> </Suspense> )} </> );}Nothing loads until you click. Then line 14 mounts the boundary, Orders asks for the data, and you wait a full second behind Loading orders….
Activity renders its tab in the background on the first render, so the orders load before anyone asks, and the click shows them at once. With conditional rendering, loading only starts on the click.Pre-rendering only loads what's read while rendering. A request in an effect, like the ones in Fetching data, waits until the tab is shown, because hidden content runs no effects. That's one more reason the Suspense way of reading data, through a cache, works well with Activity.
Activity, conditional rendering, or display: none
You could also hide a tab with CSS, by giving its wrapper display: none, the way the Styles and classes lesson does in plain JavaScript. That keeps the component mounted too, so how is it different? Here are three stopwatches, one for each way. Let them run for a few seconds, then hide all three, wait, and show them:
import { Activity, useEffect, useState } from "react"; function Stopwatch({ name }) { const [seconds, setSeconds] = useState(0); useEffect(() => { const id = setInterval(() => setSeconds((s) => s + 1), 1000); return () => clearInterval(id); }, []); return <p>{name}: {seconds} s</p>;} export default function ThreeWays() { const [show, setShow] = useState(true); return ( <> <button onClick={() => setShow(!show)}>{show ? "Hide" : "Show"} all three</button> {show && <Stopwatch name="Conditional" />} <div style={{ display: show ? "block" : "none" }}> <Stopwatch name="CSS display: none" /> </div> <Activity mode={show ? "visible" : "hidden"}> <Stopwatch name="Activity" /> </Activity> </> );}Lines 3 to 10 are the stopwatch: an effect that adds one second every second, with a cleanup that stops it. After you show them again:
- Conditional (line 17) starts from 0. It was unmounted, and its state with it.
- CSS display: none (lines 18 to 20) counted the whole time. It was only out of sight; its effect never stopped. Every timer, connection, and subscription in a tab hidden this way keeps running, and every update renders at full speed.
- Activity (lines 21 to 23) shows the number it had when you hid it, then goes on. Its state stayed; its effect stopped and started again.
Activity keeps the state and the DOM while it stops the effects. Conditional rendering throws everything away; CSS hides the elements but leaves every effect running.In the playground, the chat from the effects section, without its render log, is hidden each of the three ways. Choose a way, wait a few seconds, type a draft, then hide the chat, wait, and show it. Watch the counter, the draft, and the console.
import { Activity, useState } from "react";import Chat from "./Chat.jsx"; export default function SupportApp() { const [visible, setVisible] = useState(true); return ( <> <button onClick={() => setVisible(!visible)}>{visible ? "Hide" : "Show"} the chat</button> <Activity mode={visible ? "visible" : "hidden"}> <Chat room="#general" /> </Activity> </> );}Wait for a few seconds online and type a draft, then hide the chat, wait, and show it. The console prints Disconnect on hide and Connect on show, with StrictMode's extra pair. The counter shows the seconds it had when you hid it, then counts on, and the draft is still there.
How to read the table: each row is one question, answered for each way of hiding.
| Question | Conditional rendering | CSS display: none | Activity |
|---|---|---|---|
| Is the state kept while hidden? | No. The component unmounts, and its state is thrown away. | Yes. The component stays mounted. | Yes. React keeps it until the tab comes back. |
| Is the DOM kept? | No. React removes the elements. | Yes. Your style hides the wrapper. | Yes. React sets display: none !important on each top element. |
| Do effects run while hidden? | No. Their cleanups ran when it unmounted. | Yes. Timers and connections keep going. | No. Cleanups run on hide, and effects run again on show. |
| Does it render while hidden? | No. It doesn't exist. | Yes, as fast as visible content. | Yes, when its props change, at a lower priority than visible content. |
| Can it render before it's first shown? | No. It renders when it's shown. | Yes, effects and all, at full priority. | Yes. Hidden on the first render, it pre-renders at low priority, with no effects. |
Hidden content isn't free: it keeps its memory, and it re-renders when its props change. Keep hidden what the reader is likely to come back to, or open next, and unmount the rest.
A support app with tabs that remember
Here is everything together. Each tab is hidden in the way that suits it, and the chat and orders tabs are the same files as above:
import { Activity, Suspense, useState } from "react";import Chat from "./Chat.jsx";import Orders from "./Orders.jsx";import Profile from "./Profile.jsx"; const tabs = [ { id: "chat", label: "Chat" }, { id: "orders", label: "Orders" }, { id: "profile", label: "Profile" },]; export default function SupportApp() { const [tab, setTab] = useState("chat"); return ( <> <nav> {tabs.map(({ id, label }) => ( <button key={id} onClick={() => setTab(id)} aria-pressed={tab === id}> {label} </button> ))} </nav> <Activity mode={tab === "chat" ? "visible" : "hidden"}> <Chat room="#general" /> </Activity> <Activity mode={tab === "orders" ? "visible" : "hidden"}> <Suspense fallback={<p>Loading orders…</p>}> <Orders /> </Suspense> </Activity> {tab === "profile" && <Profile />} </> );}Lines 6 to 10 list the tabs, and lines 17 to 21 make a button for each, with aria-pressed telling screen readers which one is open. The chat on lines 23 to 25 keeps its draft and disconnects while hidden. The orders on lines 26 to 30 start hidden, so they pre-render and load as the app opens. Profile, on line 31, has nothing to keep and nothing to load, so plain conditional rendering is enough.
Write a draft, open Orders (already loaded), then Profile, then Chat: the draft is there, and the console shows Disconnect from #general when you left the chat and Connect to #general when you came back, with StrictMode's extra pair in development.
- A checkout form that must start empty every time it opens
- Fifty closed chat windows the reader won't open again
- A small menu with nothing worth keeping, opened and closed by a button
- A chat tab with a half-written message the reader will come back to
- The tab the reader will likely open next, which reads its data with
use - A video tab that should keep its place when the reader comes back
- A decorative picture that hides on narrow screens with a media query
- Buttons hidden when the page prints, with
@media print
Sort each situation: conditional rendering, Activity, or CSS display: none.
Common mix-ups
- "Hidden means unmounted." No. A hidden Activity keeps its components mounted, with their state and DOM. Only their effects are cleaned up.
- "Activity is the same as
display: none." No. CSS only hides the elements, and every effect keeps running. Activity also stops the effects and lowers the priority of hidden renders. - "A hidden tab doesn't render at all." No. It renders when its props change, and on its first render if it starts hidden, at a lower priority than what's on screen.
- "Pre-rendering loads data from effects too." No. Hidden content runs no effects, so only data read while rendering, like
usewith a cached promise, starts loading early. - "Activity stops everything the tab does." No. It stops effects. A timer you start in a click handler, or a
<video>that's playing, keeps going unless an effect's cleanup stops it. - "Keeping every tab hidden is always better." No. Hidden content takes memory and still re-renders. Unmount what the reader won't come back to, and anything that should start fresh.
- "The extra Disconnect and Connect mean my code is wrong." No. StrictMode adds them in development to check your cleanup. In production, each runs once.
Practice exercises
The exercises use the same support app and the same pretend server. Change the code with Edit & run, and press Run again to start over.
These tabs use &&. You type Hi in the chat box, open Profile, and come back to Chat. What is in the draft box? Answer first, then press Edit & run and try it.
import { useState } from "react";
import Chat from "./Chat.jsx";
import Profile from "./Profile.jsx";
export default function SupportApp() {
const [tab, setTab] = useState("chat");
return (
<>
<nav>
<button onClick={() => setTab("chat")}>Chat</button>
<button onClick={() => setTab("profile")}>Profile</button>
</nav>
{tab === "chat" && <Chat />}
{tab === "profile" && <Profile />}
</>
);
}import { useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> </nav> {tab === "chat" && <Chat />} {tab === "profile" && <Profile />} </> );}Line 13 removes Chat while Profile is open, so its state is thrown away. Coming back mounts a new Chat, whose draft starts at "".
Change these tabs so the draft survives a trip to Profile, without changing Chat. Which mode does the tab that isn't selected get?
import { useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> </nav> {tab === "chat" && <Chat />} {tab === "profile" && <Profile />} </> );}import { Activity, useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> </nav> <Activity mode={tab === "chat" ? "visible" : "hidden"}> <Chat /> </Activity> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> </> );}Each tab is wrapped in an Activity boundary on lines 13 to 18. The tab that isn't selected gets "hidden", so Chat stays mounted with its draft while Profile is open.
The chat is open and connected to #general. You click Profile. Which line about the connection does the console print?
import { Activity, useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); const [room, setRoom] = useState("#general"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> <button onClick={() => setRoom("#orders")}>Move to #orders</button> </nav> <Activity mode={tab === "chat" ? "visible" : "hidden"}> <Chat room={room} /> </Activity> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> </> );}import { Activity, useState } from "react";import Chat from "./Chat.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("chat"); const [room, setRoom] = useState("#general"); return ( <> <nav> <button onClick={() => setTab("chat")}>Chat</button> <button onClick={() => setTab("profile")}>Profile</button> <button onClick={() => setRoom("#orders")}>Move to #orders</button> </nav> <Activity mode={tab === "chat" ? "visible" : "hidden"}> <Chat room={room} /> </Activity> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> </> );}Clicking Profile hides the chat, so React runs its effect's cleanup, which logs Disconnect from #general and stops the timer. The draft and the seconds stay.
Make the orders start loading as the app opens, so the tab is ready when the reader opens it. After your change, you click Orders 2 seconds after the page opens. How long does Loading orders… show?
import { Suspense, useState } from "react";import Orders from "./Orders.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("profile"); return ( <> <nav> <button onClick={() => setTab("profile")}>Profile</button> <button onClick={() => setTab("orders")}>Orders</button> </nav> {tab === "profile" && <Profile />} {tab === "orders" && ( <Suspense fallback={<p>Loading orders…</p>}> <Orders /> </Suspense> )} </> );}import { Activity, Suspense, useState } from "react";import Orders from "./Orders.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("profile"); return ( <> <nav> <button onClick={() => setTab("profile")}>Profile</button> <button onClick={() => setTab("orders")}>Orders</button> </nav> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> <Activity mode={tab === "orders" ? "visible" : "hidden"}> <Suspense fallback={<p>Loading orders…</p>}> <Orders /> </Suspense> </Activity> </> );}The orders boundary on lines 16 to 20 starts hidden, so React pre-renders Orders, and Load /orders runs as the page opens. Two seconds later the data is there, so the click shows it at once.
The radio keeps playing after you leave its tab: the console prints Playing the radio… every second. Fix the Radio.jsx tab so hiding the tab stops it, and showing it plays on. After your fix, how many times does the console print Playing the radio… while the tab is hidden?
import { Activity, useState } from "react";import Radio from "./Radio.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("radio"); return ( <> <nav> <button onClick={() => setTab("radio")}>Radio</button> <button onClick={() => setTab("profile")}>Profile</button> </nav> <Activity mode={tab === "radio" ? "visible" : "hidden"}> <Radio /> </Activity> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> </> );}import { Activity, useState } from "react";import Radio from "./Radio.jsx";import Profile from "./Profile.jsx"; export default function SupportApp() { const [tab, setTab] = useState("radio"); return ( <> <nav> <button onClick={() => setTab("radio")}>Radio</button> <button onClick={() => setTab("profile")}>Profile</button> </nav> <Activity mode={tab === "radio" ? "visible" : "hidden"}> <Radio /> </Activity> <Activity mode={tab === "profile" ? "visible" : "hidden"}> <Profile /> </Activity> </> );}In the Radio.jsx tab, lines 6 to 10 start the timer in an effect while playing is true, and the cleanup on line 9 clears it. Hiding the tab runs that cleanup, so the radio stops; showing it runs the effect again, so it plays on.
The checkout form should be empty every time it opens, but it remembers the last address. Fix Cart. Which way of hiding gives an empty form each time it opens?
import { Activity, useState } from "react";import CheckoutForm from "./CheckoutForm.jsx"; export default function Cart() { const [open, setOpen] = useState(false); return ( <> <button onClick={() => setOpen(true)}>Check out</button> <Activity mode={open ? "visible" : "hidden"}> <CheckoutForm onClose={() => setOpen(false)} /> </Activity> </> );}import { useState } from "react";import CheckoutForm from "./CheckoutForm.jsx"; export default function Cart() { const [open, setOpen] = useState(false); return ( <> <button onClick={() => setOpen(true)}>Check out</button> {open && <CheckoutForm onClose={() => setOpen(false)} />} </> );}Line 9 renders the form only while it's open. Paying closes it, which unmounts it and throws its state away, so the next Check out gets an empty form.
Check your understanding
For each question, ask what the hidden part keeps (state, DOM) and what stops (effects).
Question 1 of 8A tab is inside
<Activity mode="hidden">. What happens to its DOM elements?Choose an answer to see the explanation.
Question 2 of 8A component in a tab connects to a chat room in an effect. The tab becomes hidden, then visible. What does the effect do?
Choose an answer to see the explanation.
Question 3 of 8All three stopwatches tick for 3 seconds. You hide them for 2 seconds, then show them. What do they show right after?
Read the code, then predictJSX{show && <Stopwatch name="Conditional" />} <div style={{ display: show ? "block" : "none" }}> <Stopwatch name="CSS display: none" /> </div> <Activity mode={show ? "visible" : "hidden"}> <Stopwatch name="Activity" /> </Activity>Choose an answer to see the explanation.
Question 4 of 8A tab starts inside
<Activity mode="hidden">and reads its orders withuse(fetchData("/orders")). When does the request start?Choose an answer to see the explanation.
Question 5 of 8The hidden tab loads its orders in a
useEffectinstead. When does that request start?Choose an answer to see the explanation.
Question 6 of 8A hidden tab's props change, like the chat room it shows. What does React do?
Choose an answer to see the explanation.
Question 7 of 8What does this render?
Read the code, then predictJSXfunction Greeting() { return "Hello, Ravi"; } <Activity mode="hidden"> <Greeting /> </Activity>Choose an answer to see the explanation.
Question 8 of 8Which React release made
<Activity>a stable API?Choose an answer to see the explanation.
Key takeaways
<Activity mode="visible">shows its children;mode="hidden"hides them without unmounting. It's stable since React 19.2.- Hidden children keep their state and their DOM, so drafts, open sections, and scroll positions survive. React hides the DOM with
display: none !important. - Hiding cleans up their effects; showing runs them again. Things started outside effects need a cleanup of their own.
- Hidden children still re-render when their props change, at a lower priority than what's on screen.
- A boundary that starts hidden pre-renders its children, so data read with
usestarts loading before the click. - Use conditional rendering for what should start fresh, CSS for pure styling, and Activity for what the reader will come back to.
Remember the one-liner.
Hidden keeps the state and the DOM, stops the effects, and renders quietly in the background.
Coming next: View transitions. It animates between screens and states, with React's ViewTransition and the browser's View Transition API.