Offscreen & Activity: The Hidden-but-Alive Tree
What you'll learn
- How React can keep a subtree fully mounted, state and DOM, while it's invisible
- What "hidden mode" does: background-priority rendering, disconnected effects, preserved DOM
- The
<Activity>component and the tab interface that never loses your work - Prewarming: rendering the next screen before the user asks for it
- When preserving hidden trees is the wrong trade
You've built this bug a hundred times: a tabbed interface where switching tabs destroys everything, scroll position, the half-filled form, the fetched data. The "fix" with conditional rendering ({tab === 'a' && <TabA />}) unmounts the loser, and unmounting means state destruction (Part 3's preserve/reset map). What if React could make a subtree invisible without unmounting it, keep the state, keep the DOM, just... hide it, and pause its work? That machinery exists. Suspense has been quietly using it all along, and it's surfacing as an API you can use directly.
The concept: hidden but alive
React can wrap any subtree in an Offscreen boundary with two modes:
- visible, the normal world: renders at the update's priority, effects run, users see it.
- hidden, the tree stays mounted: its state is preserved, its DOM nodes are preserved (visually removed from view), but it renders at the lowest priority, background work, and its effects disconnect while hidden.
Jargon: "offscreen tree". A mounted subtree that React maintains (state, fibers, DOM) but does not show. It can receive updates, render in the background, and be revealed later without remounting.
This is not hypothetical machinery. It's exactly how Suspense keeps your real content alive under a fallback (Part 5): when new content suspends during a transition, React hides the current tree offscreen rather than unmounting it, which is why going "back" from a fallback restores your UI instantly, state intact.
What hidden mode actually does: four behaviors
1. Rendering happens at the lowest priority. Updates to a hidden tree are background work, scheduled below everything visible (Part 4's lanes). A hidden tab re-rendering its data never blocks typing in the visible one.
2. Effects disconnect while hidden, and reconnect when shown. Conceptually, React runs your effects' cleanup when the tree hides, and setup again when it's revealed:
Pseudocode model, not real source:
// Mode change, conceptually:function setMode(tree, mode) {if (mode === 'hidden') {runEffectCleanups(tree); // timers, subscriptions pauseconcealDOM(tree); // nodes kept, removed from view} else {revealDOM(tree); // same nodes, back in viewrunEffectSetups(tree); // timers, subscriptions resume}// State: untouched either way. No unmount, no remount.}
Why is pausing effects usually what you want? A hidden tab polling a server every 5 seconds is burning battery and bandwidth for an audience of nobody. A hidden video keep playing is a bug, not a feature. Effects exist to synchronize with external systems; hidden UI has no business synchronizing. (This is also why StrictMode's setup→cleanup→setup drill from chapter 1 of this part matters: effects that can't survive disconnect/reconnect were already broken.)
3. The DOM stays. Scroll positions, text selections, <video> progress, canvas contents, everything the browser holds in nodes survives hiding, because the nodes survive. Contrast with unmounting, where the nodes are destroyed and all of it evaporates.
4. Updates scheduled while hidden don't reveal it. If a hidden tree's state updates (a subscription flushes, a parent re-renders), React renders it in the background and keeps it hidden. Fresh content is waiting when the user returns, no reveal-until-ready surprises.
The Activity component: tabs that never lose your work
The experimental API surfacing this machinery is <Activity mode="visible" | "hidden">. (Experimental means: the name and export path may shift; the concept is what this chapter is really about.) Here it is solving the tab problem completely:
import { useState, useEffect, Activity } from 'react';
function FeedTab() {
return (
<div style={{ height: 200, overflow: 'auto' }}>
{Array.from({ length: 100 }, (_, i) => (
<p key={i}>Post {i} — scroll me down, then switch tabs</p>
))}
</div>
);
}
function DraftTab() {
const [draft, setDraft] = useState('');
return (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Type a draft, switch tabs, come back…"
rows={6}
/>
);
}
function DataTab() {
const [data, setData] = useState(null);
useEffect(() => {
let alive = true;
fakeFetch('/api/stats').then((d) => alive && setData(d));
return () => {
alive = false;
};
}, []);
return <p>{data ? `Loaded: ${data.summary}` : 'Loading…'}</p>;
}
export default function App() {
const [tab, setTab] = useState('feed');
return (
<>
<nav>
<button onClick={() => setTab('feed')}>Feed</button>
<button onClick={() => setTab('draft')}>Draft</button>
<button onClick={() => setTab('data')}>Data</button>
</nav>
<Activity mode={tab === 'feed' ? 'visible' : 'hidden'}>
<FeedTab />
</Activity>
<Activity mode={tab === 'draft' ? 'visible' : 'hidden'}>
<DraftTab />
</Activity>
<Activity mode={tab === 'data' ? 'visible' : 'hidden'}>
<DataTab />
</Activity>
</>
);
}
What happens when you switch from Draft to Feed and back:
- Click "Feed".
Appre-renders; Draft's Activity flips tohidden, Feed's tovisible. - Draft's subtree is not unmounted. Its
draftstate survives untouched. Its textarea DOM node is kept and concealed, scroll position, cursor, selection intact. Any effects run their cleanup (pause). - Feed's subtree is revealed with its preserved DOM, including the scroll position you left it at, and its effects reconnect.
- Click back to "Draft": the half-written text is right there, because nothing was ever destroyed.
The contrast, for one line of code:
// ❌ Conditional rendering: switching tabs UNMOUNTS the loser.
// State resets. Scroll resets. Data refetches. Drafts vanish.
{tab === 'draft' && <DraftTab />}
Same visible behavior on first load; completely different behavior on every switch after. Wizards, multi-step forms, and media pages are the same pattern: anything where "going back" should mean back to how I left it.
Prewarming: render the next screen before it's asked for
Hidden mode inverts into a superpower: you can render a tree the user hasn't asked for yet, in the background, at lowest priority, so revealing it is instant.
The pattern, conceptually: when the user hovers a "Next" link (or is otherwise likely to navigate), flip the destination's Activity to hidden early. React renders it in spare time, fetching code, running its render, preparing its DOM, while the user is still reading. When they click, flip to visible. No spinner, no skeleton: the screen was already built behind the curtain. Updates scheduled while hidden never reveal it prematurely, so there's no risk of flashing unfinished UI. This is the manual version of what Suspense-based routers do automatically, and it composes with transitions (Part 4): start the background render at low priority, reveal at a moment of your choosing.
When NOT to use it
Preservation is not free:
- Memory. A hidden tree holds its state, its fibers, and its DOM nodes. Keeping six heavy tabs alive forever is a real footprint on low-end devices. Hide what users return to; unmount what they won't.
- Background work still costs. Lowest priority is not zero priority, a huge hidden tree re-rendering often still burns cycles. If the hidden content goes stale fast and re-rendering it is expensive, remounting fresh on demand may be cheaper.
- Sometimes a reset is the feature. If "switching away" should wipe the form, you want the unmount, or the explicit
keyreset from Part 8, not preservation. Offscreen is for when losing state is the bug, not when it's the spec.
The thread that ties the series together
Notice what this machinery explains, retroactively:
- Suspense (Part 5): when a transition to suspending content begins, the current content goes offscreen instead of unmounting, which is why its state survives and why the fallback can be withdrawn cleanly.
- Transitions (Part 4): "keep showing the old UI while preparing the new one" is literally one tree visible while another renders hidden at low priority.
useDeferredValue's stale-then-fresh rendering rides the same rails.
One mechanism, mounted-but-hidden trees, underneath three features that looked unrelated.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception: Hiding a tree with Activity is like
display: nonein CSS. Reality: the visual result is similar, but CSS hiding leaves effects running and renders at normal priority; hidden mode additionally disconnects effects and renders in the background. It's a React-level lifecycle, not a style. - Misconception: Hidden trees are frozen, nothing happens in them. Reality: they render in the background whenever updates reach them; they just never block visible work and never reveal themselves.
- Misconception: Effects in a hidden tree keep running. Reality: they're cleaned up on hide and set up again on reveal, precisely so hidden UI stops synchronizing with external systems.
- Misconception: Preserving everything is strictly better UX. Reality: memory and background work are real costs, and sometimes a reset is the desired behavior. Preserve deliberately.
- Misconception: This is a brand-new, separate subsystem. Reality: it's the machinery Suspense and transitions already use;
<Activity>just exposes it to you directly (as an experimental API, names may shift). - Misconception: You must prewarm for hidden mode to be useful. Reality: prewarming is an optional inversion, preservation on its own already fixes tabs, wizards, and back-navigation.
Why it works this way
- Unmounting is the only alternative, and it's lossy. React's model ties state to mounted position (Part 3). If hiding required unmounting, preserving state would require lifting it all to parents, architecturally invasive and still lossy for DOM-held state like scroll. A mounted-but-hidden mode is the only complete answer.
- Background priority makes preservation affordable. Hidden trees could otherwise compete with visible work; scheduling them lowest means keeping them alive rarely costs the user anything perceptible.
- Effect disconnection matches the mental model. Effects synchronize visible UI with external systems. Disconnecting on hide turns "is this on screen?" from an implicit leak (timers firing for nobody) into explicit, correct behavior.
- DOM preservation is the only way to preserve browser-held state. Scroll, selection, media position, canvas pixels live in nodes, not in React state. Keep the nodes or lose the state, there is no third option.
Try it yourself
- Build the three-tab example. Scroll the Feed halfway, half-fill the Draft, then cycle tabs. Confirm everything is exactly as you left it, then swap the Activities for
&&conditionals and feel the difference. - Add a
setIntervallogger inside an effect inDraftTab. Switch tabs and watch logging stop while hidden, resume when revealed. That's effect disconnection, live. - Put
console.count('DataTab render')inDataTab, trigger parent re-renders while it's hidden, and confirm it renders in the background without appearing. - Simulate prewarming: render
DataTabhidden on mount, then reveal it after three seconds from a timer. Compare the reveal against a cold conditional mount, log when its fetch resolves in each version.
Recap
- Offscreen trees stay mounted: state and DOM preserved, visually hidden, no unmount, no reset.
- Hidden mode: renders at lowest priority, effects disconnect (cleanup on hide, setup on reveal), DOM kept, background updates never reveal it.
<Activity mode={...}>(experimental) exposes this directly: tabs, wizards, and back-navigation that lose nothing.- Prewarming: render the next screen hidden in the background; reveal is instant because the work already happened.
- Costs are memory and background work, and sometimes a reset is the spec. Preserve deliberately, not universally.
- This one mechanism is how Suspense keeps content alive under fallbacks and how transitions keep old UI on screen while preparing new UI.