7.3 Activity vs Suspense (React 19)
React 19 ships a second boundary that, like Suspense, can hide part of your tree — it is called <Activity>. They look similar from the outside, so it is easy to assume one is just a flavour of the other. This lesson answers the practical question: what does Activity actually do, how is its machinery different from Suspense, and when would you reach for each?
React 19 has two boundary components that both touch the same Offscreen fiber and the same lane system, yet they solve different problems. We will build the picture one layer at a time: first what Activity is, then how it defers work, then why hidden content survives, and finally a head-to-head with Suspense.
Two boundaries, two jobs
Start with the one-line difference. Suspense waits for something that isn't ready yet — data, or a lazily-loaded chunk of code — and shows a fallback in the meantime. Activity deals with something that the user simply can't see right now — an off-screen tab, a hidden panel — and quietly puts off rendering it until there's spare time, without ever showing a fallback.
Here is the whole comparison in one table. Don't try to absorb every row yet; we'll earn each one below.
| Aspect | Suspense | Activity |
|---|---|---|
| Purpose | Handle async data / code loading; show fallback while waiting | Defer hidden-subtree rendering; SSR selective hydration |
| Fiber tag | 13 | 31 |
| Introduced | React 16.6 | React 19 |
| Trigger | Child throws a promise exception | mode="hidden" prop → Offscreen child defers via OffscreenLane |
| Control flow | Exception-based (try/catch style) | Lane-based (bitwise checks) |
| Visible UI | Shows developer-supplied fallback while waiting | Defers hidden content silently; no fallback |
| Works in | Client + SSR (universal) | Client (Offscreen deferral) + SSR (adds selective hydration protocol) |
| User-facing API | Yes — <Suspense fallback={...}> | Internal in React 19 (may be exposed later) |
What Activity is
Activity is a fiber with tag 31. You give it a mode prop — either "visible" or "hidden" — and a subtree to wrap. A page might use several at once, one per tab or panel:
<Parent>
<Activity mode="hidden">A</Activity> {/* Activity A — will be deferred */}
<Activity mode="visible">B</Activity> {/* Activity B — hydrates first */}
<Activity mode="hidden">C</Activity> {/* Activity C — will be deferred */}
</Parent>
Activity itself is barely more than a wrapper. When it mounts, it creates a single child: an Offscreen fiber (tag 22). The Offscreen child is where the real deferral logic lives. So when we talk about "how Activity defers," we are really talking about what its Offscreen child does.
The real code
// Activity's own code — simple wrapper
if (null === current) { // mount
if (isHydrating) { // FALSE — skip entire hydration block
// ...
}
return mountActivityChildren(workInProgress, nextProps);
}
function mountActivityChildren(workInProgress, nextProps) {
nextProps = mountWorkInProgressOffscreenFiber(
{ mode: nextProps.mode, children: nextProps.children },
workInProgress.mode
);
workInProgress.child = nextProps;
nextProps.return = workInProgress;
return nextProps;
}
How Activity defers: lanes, not throws
Suspense's trigger is dramatic: a child throws. Activity's trigger is quiet: it just checks a number. That number is a lane — a single bit in a 32-bit priority bitmask (you met lanes back in Module 3). One special lane matters here.
The real code
// Two lanes that matter most for this chapter:
SyncLane = 2; // 0b...0010 (highest priority, regular updates)
OffscreenLane = 536870912; // 0b00100000000000000000000000000000 (lowest, deferred)
// The gate used everywhere:
function checkScheduledUpdateOrContext(current, renderLanes) {
if (0 !== (current.lanes & renderLanes)) return true;
// Any overlapping bits → process this fiber
// No overlap → skip (bailout)
}
// Example — Activity A is deferred:
// Activity A.lanes = 536870912 (OffscreenLane)
// renderLanes = 2 (SyncLane)
// 536870912 & 2 = 0 → skip Activity A this pass
OffscreenLane is the lowest priority lane. A normal render runs with a higher-priority lane like SyncLane (2). Because the OffscreenLane bit is not set in that render's renderLanes, the bitwise & comes out zero, and React skips the hidden content. That "skip" is the whole trick.
The Offscreen child makes the decision in updateOffscreenComponent. When mode is "hidden" there are three branches:
The real code
function updateOffscreenComponent(current, workInProgress, renderLanes, nextProps) {
if ("hidden" === nextProps.mode) {
// ─── Branch 1: DidCapture — suspended while hidden ───
if (0 !== (workInProgress.flags & 128)) {
return deferHiddenOffscreenComponent(...); // re-defer with accumulated baseLanes
}
// ─── Branch 2: OffscreenLane IS in renderLanes — render hidden children now ───
if (0 !== (renderLanes & 536870912)) {
workInProgress.memoizedState = { baseLanes: 0, cachePool: null };
// fall through to reconcileChildren …
}
// ─── Branch 3: OffscreenLane NOT in renderLanes — defer ───
else {
workInProgress.lanes = 536870912; // schedule for OffscreenLane
return deferHiddenOffscreenComponent(...); // returns null — stop here
}
}
// visible mode — reconcile normally
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
The common case is Branch 3. During a normal render the OffscreenLane bit isn't present, so Activity stamps workInProgress.lanes = 536870912 on itself — a note that says "come back for me on the OffscreenLane" — and returns null to stop descending. Later, when the scheduler runs a low-priority pass that does include OffscreenLane, Branch 2 fires and the hidden children finally render.
Step 1: Activity (tag 31) creates Offscreen child
Activity (tag 31)
└─ Offscreen (tag 22, mode="hidden")
Step 2: Offscreen defers (OffscreenLane not in renderLanes)
Activity (tag 31)
└─ Offscreen (tag 22, lanes=536870912)
└─ (no children yet — deferred by Offscreen, not Activity!)
Later — scheduler picks up OffscreenLane:
renderLanes = 536870912 → Branch 2 → reconcileChildren → Component A
commit phase: hideInstance → display: none
Compare this to Suspense, whose resume trigger is a promise resolving at some unpredictable moment. Activity's resume is predictable: a scheduled, priority-ordered pass on the OffscreenLane. There is no promise, no .then listener — just the scheduler coming back when it has spare time.
Show the full source — deferHiddenOffscreenComponent
Branches 1 and 3 both end in this helper. It records which lanes were deferred, sets up the contexts the hidden subtree will need later, and returns null so React stops descending — without deleting anything.
function deferHiddenOffscreenComponent(
current, workInProgress, nextBaseLanes, renderLanes, remainingChildLanes
) {
// 1. Store deferral info
workInProgress.memoizedState = {
baseLanes: nextBaseLanes, // which lanes were deferred
cachePool: peekCacheFromPool()
};
// 2. Set up contexts for hidden content
reuseHiddenContextOnStack(workInProgress);
pushOffscreenSuspenseHandler(workInProgress);
// 3. Store child lanes so parent doesn't bail out
workInProgress.childLanes = remainingChildLanes;
// 4. Return null — DON'T create children yet
return null;
}
Hidden is not gone: state survives
Here is the property that makes Activity worth having. When Activity hides a subtree, the component keeps its state. Switch a tab away and back, and its scroll position, its inputs, its fetched data are all still there. To see why, separate the three layers React is working with.
┌─────────────────────────────────────────────────────────────┐
│ LAYER 1: React Fiber Tree (In Memory) │
│ • Component instances and their relationships │
│ • All React state (useState hooks, this.state) │
│ • Effect hooks and their cleanup functions │
│ │
│ Status when hidden: FULLY PRESERVED │
│ Why: createWorkInProgress copies child + memoizedState │
└─────────────────────────────────────────────────────────────┘
↓ Fiber → DOM mapping
┌─────────────────────────────────────────────────────────────┐
│ LAYER 2: DOM Nodes (In Document) │
│ • Actual HTML elements (<div>, <button>, etc.) │
│ • Event listeners attached to elements │
│ • Stored in: fiber.stateNode = <div> │
│ │
│ Status when hidden: STILL IN DOCUMENT │
│ Why: hideInstance() only sets CSS; never calls │
│ element.remove() or parentNode.removeChild() │
└─────────────────────────────────────────────────────────────┘
↓ CSS styling
┌─────────────────────────────────────────────────────────────┐
│ LAYER 3: Visual Display (CSS) │
│ • element.style.display property │
│ │
│ Status when hidden: display: "none" │
│ Status when visible: display: "" (browser default) │
│ │
│ THIS IS THE ONLY LAYER ACTIVITY MODIFIES │
└─────────────────────────────────────────────────────────────┘
Activity touches only Layer 3. Hiding a node is one CSS property change in the commit phase — nothing is unmounted, nothing is removed from the document:
The real code — hideInstance and unhideInstance
function hideInstance(instance) {
instance = instance.style;
"function" === typeof instance.setProperty
? instance.setProperty("display", "none", "important")
: (instance.display = "none");
}
function unhideInstance(instance, props) {
props = props[STYLE];
props =
void 0 !== props && null !== props && props.hasOwnProperty("display")
? props.display
: null;
instance.style.display =
null == props || "boolean" === typeof props ? "" : ("" + props).trim();
}
function hideTextInstance(textInstance) { textInstance.nodeValue = ""; }
function unhideTextInstance(textInstance, text) { textInstance.nodeValue = text; }
And Layer 1 is preserved because of one line in createWorkInProgress: the child pointer is copied across before Offscreen even decides whether to descend. So when updateOffscreenComponent returns null, the children are already attached — they were never thrown away.
The real code — createWorkInProgress
function createWorkInProgress(current, pendingProps) {
var workInProgress = current.alternate;
// … create or reuse workInProgress fiber …
// KEY LINE — children already copied before Offscreen decides anything:
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
// … copies all fiber properties …
return workInProgress;
}
Tabs are the textbook use. Each tab is an Activity whose mode follows whether it is the active one:
<Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
<HomeTab /> {/* state: { items: [...], scrollPosition: 500 } */}
</Activity>
<Activity mode={activeTab === 'profile' ? 'visible' : 'hidden'}>
<ProfileTab />
</Activity>
1. Initial: activeTab = 'home'
HomeTab — Fiber ✓ DOM ✓ display: "" state: { scrollPosition: 500 }
ProfileTab — Fiber ✓ DOM ✓ display: "none" state: { user: {...} }
2. User clicks Profile: activeTab = 'profile'
HomeTab — Fiber ✓ DOM ✓ display: "none" state: { scrollPosition: 500 } ← preserved!
ProfileTab — Fiber ✓ DOM ✓ display: "" state: { user: {...} } ← preserved!
(only the CSS layer changed — nothing was destroyed)
3. User clicks Home again: activeTab = 'home'
HomeTab — scrollPosition still 500! Instant restoration, just a CSS toggle
This is exactly what plain conditional rendering cannot give you. {show && <Component />} removes the component from the tree when show is false — its state is gone, and showing it again re-mounts from scratch:
| Aspect | Activity (mode="hidden") | Conditional ({show && <Component />}) |
|---|---|---|
| Fiber tree | Stays in tree | Removed from tree |
| Component state | Preserved in memory | Destroyed, reset on re-mount |
| DOM nodes | Stay in document | Removed from document |
| Scroll position | Preserved | Lost (scroll resets) |
| Restoration speed | Instant (just CSS) | Slow (re-mount + re-fetch) |
| Memory usage | Higher (keeps everything) | Lower (frees memory) |
Activity vs Suspense, side by side
Now the contrast can be stated precisely. Suspense is driven by an exception: a child throws a promise, React catches it at the boundary, flips a flag, and swaps in the fallback. Activity is driven by a lane: a bitwise check decides whether the hidden subtree runs this pass. Same Offscreen plumbing underneath; completely different control flow on top.
Look at what each boundary leaves in the fiber tree. A suspended Suspense holds two children — the hidden primary content and a visible fallback Fragment. A hidden Activity holds just a shallow Offscreen with nothing rendered under it yet:
Suspense (suspended): Activity (hidden, SSR): ───────────────────── ───────────────────── Suspense (tag 13) Activity (tag 31) ├─ Offscreen (mode="hidden") └─ Offscreen (shallow, no children yet) │ └─ [children — not rendered] └─ Fragment (fallback) └─ LoadingSpinner Suspense returns: Fallback Fragment Activity returns: null
That difference in the return value is the mechanism in miniature. Suspense returns the fallback Fragment so React renders the loading UI next. Activity returns null so React skips the subtree entirely — there is nothing to show, and that's the point.
Despite the differences, the two share a surprising amount of machinery:
Both components: │ ├─► Create Offscreen wrapper (tag 22) │ ├─► Use bailoutOffscreenComponent() (returns .sibling or null) │ ├─► Support mode="hidden" and mode="visible" on Offscreen │ ├─► Can return null (but for different reasons) │ └─► Integrate with the lane system for scheduling
The full head-to-head, for reference once the mechanisms have clicked:
| Characteristic | Suspense | Activity |
|---|---|---|
| Stance | Reactive — responds to runtime exceptions | Proactive — decides upfront what to defer |
| Initiated by | Developer wraps components that might suspend | React determines priority based on visibility |
| Style | Optimistic — tries to render, handles failures | Planned — knows in advance what to skip |
| Dimension | Temporal ("waiting for async operations") | Spatial ("visible vs off-screen") |
| Works everywhere | Yes — client + SSR | Deferral yes; selective hydration SSR only |
Suspense: "What happens when we DON'T have data yet?"
→ Show fallback, wait for promise, then render
→ REACTIVE to async conditions
Activity: "What should we render FIRST during hydration?"
→ Render visible content, defer hidden content
→ PROACTIVE scheduling decision
Shared philosophical core:
"Suspense defers SHOWING content until it is ready.
Activity keeps content MOUNTED but HIDDEN with state preserved."