web-frontend

React Activity Component Tutorial: Stateful Tabs (2026)

June 12, 2026

React Activity Component Tutorial: Stateful Tabs (2026)

React's <Activity> component (stable since React 19.2) hides UI without unmounting it: children keep their state and DOM, get visually hidden with display: none, and have their Effects cleaned up while hidden. Set mode="hidden" on initial render and Activity pre-renders content at lower priority before users see it.

TL;DR

In this React Activity component tutorial you'll build a tabbed notes UI three ways — conditional rendering (loses state), CSS display: none (leaks live subscriptions), and <Activity> (keeps state, pauses Effects) — then pre-render a hidden tab so it appears faster when the user opens it. Every behavioral claim below was executed as a Vitest + React Testing Library suite against react 19.2.7: 9/9 tests pass, TypeScript strict clean. Budget roughly 25–30 minutes of build time.

What you'll learn

  • Why conditional rendering destroys tab state — and why CSS hiding is worse than it looks
  • How React Activity hidden mode works: state kept, DOM kept, Effects cleaned up
  • How to preserve tab state in React with <Activity mode> — a three-line change
  • How to pre-render hidden UI so the next tab appears with reduced loading time
  • How to prove all of it with tests, plus the documented caveats most tutorials gloss over (text-only children, <video> side effects, Effect-based fetching)

Prerequisites

  • Node.js 22+ (Node 24 LTS recommended)
  • react and react-dom 19.2 or later — this tutorial pins 19.2.7 (the current latest, published June 1, 2026)1. Activity is a stable top-level export in 19.2; there is no unstable_ prefix anymore2
  • TypeScript 6.0.3, Vitest 4.1.8, @testing-library/react 16.3.2, jsdom 29.1.1 for the verification suite
  • Comfort with useState and useEffect cleanup. If Effect cleanup is hazy, read our useEffect guide on dependency arrays and cleanup first

Set up a scratch project:

mkdir activity-demo && cd activity-demo
npm init -y && npm pkg set type=module
npm install react@19.2.7 react-dom@19.2.7
npm install -D typescript@6.0.3 vitest@4.1.8 @testing-library/react@16.3.2 \
  jsdom@29.1.1 @types/react@19.2.17 @types/react-dom@19.2.3 @vitejs/plugin-react@6.0.2

Add a vitest.config.ts:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: { environment: 'jsdom', globals: true },
});

And a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["vitest/globals"]
  },
  "include": ["src"]
}

Step 1 — Build a tab with state worth losing

What is the React Activity component for? Its headline job is UI you want to hide without forgetting. So first, build a component with two kinds of forgettable state — React state (a controlled draft) and an Effect-managed subscription. Save this as src/TabPanel.tsx:

import { useEffect, useState } from 'react';

export const connectionLog: string[] = [];

export function NotesTab({ channel }: { channel: string }) {
  const [draft, setDraft] = useState('');

  useEffect(() => {
    connectionLog.push(`subscribe:${channel}`);
    return () => {
      connectionLog.push(`unsubscribe:${channel}`);
    };
  }, [channel]);

  return (
    <div>
      <label htmlFor="draft">Draft note</label>
      <textarea
        id="draft"
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
      />
      <p data-testid="draft-echo">{draft}</p>
    </div>
  );
}

The exported connectionLog array is a deliberate test seam: the Effect pushes subscribe:/unsubscribe: entries, so a test can assert exactly when React mounted and cleaned up the subscription. In a real app this would be a WebSocket or store subscription.

Step 2 — The failure you know: conditional rendering loses state

The standard tab pattern mounts only the active tab:

{tab === 'notes' && <NotesTab channel="notes-feed" />}

Type a draft, switch tabs, switch back — the draft is gone. Unmounting a component destroys its internal state3. The executed test proves both the state loss and the subscription churn:

test('conditional rendering DESTROYS state: draft is lost on unmount/remount', () => {
  render(<App />);
  fireEvent.change(screen.getByLabelText('Draft note'), {
    target: { value: 'half-written thought' },
  });
  fireEvent.click(screen.getByText('Home'));
  expect(document.querySelector('textarea')).toBeNull(); // gone

  fireEvent.click(screen.getByText('Notes'));
  expect(
    (screen.getByLabelText('Draft note') as HTMLTextAreaElement).value,
  ).toBe(''); // lost

  expect(connectionLog).toEqual([
    'subscribe:notes-feed',
    'unsubscribe:notes-feed',
    'subscribe:notes-feed', // a fresh connection every time the user returns
  ]);
});

Step 3 — The failure you don't: display none keeps Effects alive

The classic workaround — and the reason "React Activity component vs display none" is such a common search — is CSS hiding:

<div style={{ display: tab === 'notes' ? 'block' : 'none' }}>
  <NotesTab channel="notes-feed" />
</div>

State survives now — but React has no idea the subtree is hidden. The component stays fully mounted: its subscription stays live, its Effects keep running, and it re-renders at full priority with the rest of the tree. The executed test shows the silent leak:

test('CSS display:none KEEPS the effect subscribed while hidden', () => {
  render(<App />);
  expect(connectionLog).toEqual(['subscribe:notes-feed']);

  fireEvent.click(screen.getByText('Home'));
  // hidden, but NO cleanup ran — the subscription is still live
  expect(connectionLog).toEqual(['subscribe:notes-feed']);
});

One hidden tab holding a WebSocket open is a nuisance. Ten hidden panels each holding subscriptions, timers, and listeners is a performance and correctness problem.

Step 4 — The fix: react activity mode visible hidden

Does React Activity unmount effects when hidden? Yes — that's the core of the design. When an Activity boundary is hidden, React visually hides its children using display: none, destroys their Effects (running every cleanup), and preserves both React state and the DOM. While hidden, children still re-render in response to new props, at a lower priority than visible content. When the boundary becomes visible again, React reveals the children with their previous state and re-creates their Effects3.

The change from Step 2 is three lines:

import { Activity, useState } from 'react';
import { NotesTab } from './TabPanel';

export function TabApp() {
  const [tab, setTab] = useState<'home' | 'notes'>('notes');
  return (
    <>
      <button onClick={() => setTab('home')}>Home</button>
      <button onClick={() => setTab('notes')}>Notes</button>
      <Activity mode={tab === 'notes' ? 'visible' : 'hidden'}>
        <NotesTab channel="notes-feed" />
      </Activity>
    </>
  );
}

mode accepts 'visible' or 'hidden', and defaults to 'visible' if omitted3. Two executed tests confirm all three promises. First, Effects cleaned up on hide and re-created on reveal:

test('hiding cleans up Effects; revealing re-creates them', () => {
  render(<TabApp />);
  expect(connectionLog).toEqual(['subscribe:notes-feed']);

  fireEvent.click(screen.getByText('Home'));
  expect(connectionLog).toEqual([
    'subscribe:notes-feed',
    'unsubscribe:notes-feed', // Activity ran the cleanup
  ]);

  fireEvent.click(screen.getByText('Notes'));
  expect(connectionLog).toEqual([
    'subscribe:notes-feed',
    'unsubscribe:notes-feed',
    'subscribe:notes-feed', // and re-created the Effect
  ]);
});

…and state plus DOM preserved, with the hide implemented as inline display: none on the child's host element:

test('React state AND DOM survive hide/show', () => {
  render(<TabApp />);
  const textarea = screen.getByLabelText('Draft note') as HTMLTextAreaElement;
  fireEvent.change(textarea, { target: { value: 'half-written thought' } });

  fireEvent.click(screen.getByText('Home'));
  expect(document.querySelector('textarea')).not.toBeNull(); // DOM kept
  const wrapper = document.querySelector('textarea')!.closest('div')!;
  expect(wrapper.style.display).toBe('none'); // hidden, not removed

  fireEvent.click(screen.getByText('Notes'));
  expect(
    (screen.getByLabelText('Draft note') as HTMLTextAreaElement).value,
  ).toBe('half-written thought'); // draft survived
});

Because the DOM node itself survives, DOM-level state survives too: an uncontrolled input's draft value, or a <video> element's playback position3. The React docs frame the mental model precisely: conceptually, you should think of hidden Activities as being unmounted — state is saved for later, but nothing should be running3.

Step 5 — Pre-render hidden UI before the user asks for it

The second use of Activity hidden mode points forward, not backward: content the user hasn't seen yet. If an Activity boundary is hidden during its initial render, its children won't be visible — but they will still be rendered, at lower priority, without mounting their Effects3. That's pre-rendering: the hidden tab's component tree and DOM are built in the background, so when the boundary becomes visible its children can appear faster, with reduced loading times3.

<Suspense fallback={<h1>Loading…</h1>}>
  <Activity mode={tab === 'home' ? 'visible' : 'hidden'}>
    <Home />
  </Activity>
  <Activity mode={tab === 'notes' ? 'visible' : 'hidden'}>
    <NotesTab channel="notes-feed" />
  </Activity>
</Suspense>

Both executed tests pass on 19.2.7 — DOM exists before reveal, but no Effect has run:

test('hidden-on-initial-render pre-renders DOM but does NOT mount Effects', () => {
  render(
    <Activity mode="hidden">
      <NotesTab channel="prerender" />
    </Activity>,
  );
  expect(document.querySelector('textarea')).not.toBeNull(); // pre-rendered
  expect(connectionLog).toEqual([]); // no Effect ran
});

test('flipping a pre-rendered boundary to visible mounts Effects', () => {
  render(<App />); // starts hidden
  expect(connectionLog).toEqual([]);
  fireEvent.click(screen.getByText('Reveal'));
  expect(connectionLog).toEqual(['subscribe:prerender']);
});

One scoping note that the docs flag and most write-ups skip: during pre-rendering, only Suspense-enabled data sources are fetched — Suspense-enabled frameworks like Relay and Next.js, code-splitting with lazy, and reading a cached promise with use. Activity does not detect data fetched inside an Effect3. If your hidden tab fetches in useEffect, pre-rendering builds its DOM but does not warm its data — by design, since Effects don't mount while hidden.

Activity boundaries also participate in Selective Hydration in server-rendered apps: like Suspense boundaries, they divide the tree into independently hydratable units, so React can make tab buttons interactive before hydrating heavy tab content — and the docs note you can add always-visible Activity boundaries purely for that hydration win, even around content you never hide3. If you're on the App Router, this composes with the streaming patterns from our Next.js 16 streaming and use cache tutorial.

Activity vs conditional rendering vs display: none

What is the difference between Activity hidden mode and conditional rendering? Conditional rendering destroys everything on hide; CSS hiding preserves everything, including the work you wanted stopped; Activity splits the difference deliberately — it preserves the passive parts (state, DOM) and shuts down the active parts (Effects, full-priority rendering).

Behavior when hidden{cond && <X />}CSS display: none<Activity mode="hidden">
React statedestroyedkeptkept
DOM state (e.g. an input's draft)destroyedkeptkept
Effects / subscriptionscleaned upstill runningcleaned up
Re-renders on prop changesno (not mounted)yes, full priorityyes, lower priority
Can pre-render unseen contentnorenders eagerly, Effects and allyes, without Effects

Three practical rules fall out of this table. Reach for Activity when the user is likely to come back to the hidden UI and would notice a reset — tab panels, multi-step forms, expandable sidebars, a search panel with filters. Stick with plain conditional rendering when the hidden state should reset (a dialog that must open fresh every time) or when the subtree is cheap to rebuild — Activity keeps hidden trees in memory, and that's a real cost for content the user will never revisit. And treat raw CSS hiding as a layout tool, not a lifecycle tool: the moment the hidden subtree owns a subscription, a timer, or an interval, you're paying for work nobody can see.

One more behavior from the table is worth seeing concretely: hidden children are not frozen. They still re-render when their props change — React just schedules that work at a lower priority than visible updates3. The executed render-log test confirms a hidden child receives new prop values:

test('hidden children still re-render in response to new props', () => {
  render(<App />); // <Probe value={v}> inside a hidden Activity
  expect(renderLog).toContain('a');
  fireEvent.click(screen.getByText('bump')); // setV('b')
  expect(renderLog).toContain('b'); // hidden, but not stale
});

So a hidden tab tracks your app's latest props and state the whole time it's hidden — at a lower priority than visible content, and without running Effects. That's what makes the reveal both fast and correct: the content is already up to date when the user switches back.

Verification

Run the full suite from the project root:

npx vitest run && npx tsc --noEmit

Expected: 9 passed (9) across the failure-arc and Activity tests, and a silent (clean) typecheck. The suite executed for this tutorial covers: effect cleanup on hide, effect re-creation on reveal, state + DOM preservation, inline display: none, pre-render without Effects, mount-on-reveal, the text-only-child caveat, the default mode, and re-rendering of hidden children on prop changes (verified with a render log; that this happens at lower priority is per the React docs, not something jsdom can observe).

Troubleshooting

My hidden tab's <video> keeps playing. A hidden component's DOM is not destroyed, so DOM-level side effects persist — a playing <video>, <audio>, or an <iframe> keeps going behind display: none. The docs' fix: pause it in an Effect cleanup, using useLayoutEffect because the cleanup is tied to the UI being visually hidden and shouldn't be delayed by a re-suspending boundary or a View Transition3:

useLayoutEffect(() => {
  const video = ref.current;
  return () => {
    video?.pause();
  };
}, []);

My hidden component renders nothing at all. If the child renders only text — no wrapping DOM element — a hidden Activity produces no output, because there's no element to apply display: none to3. Verified: a hidden <Activity> around a component returning a bare string renders an empty container. Wrap text-only children in an element.

Effects I expected aren't running while hidden. That's the feature, not a bug. All children's Effects are cleaned up while hidden. If you relied on an Effect mounting to undo something, move that work into the cleanup function instead3.

Subscriptions misbehave after hide/show cycles. Hide/show cycles tend to expose Effects that are missing proper cleanup. To find them eagerly, wrap your app in <StrictMode> — the docs recommend it because it performs Activity unmounts and mounts in development to catch unexpected side effects3. The same Effect hygiene that fixes reconnect churn in our useEffectEvent tutorial is what makes components Activity-safe.

Tab state gets mixed up between dynamically rendered tabs. Like all React state, Activity-preserved state is tied to tree position. If you render Activity boundaries in a loop, give them stable key props.

Next steps

Two natural extensions from here. First, pair Activity with <ViewTransition> — an Activity becoming visible inside one (via startTransition) triggers its enter animation, and hiding triggers exit3. Second, audit your Effect hygiene with our React useEffectEvent tutorial — Activity assumes cleanups are correct, and effect events keep them small. For the broader 19.2 surface, the official release post covers what shipped alongside Activity2.

Footnotes

  1. npm registry metadata for react, checked June 12, 2026 — latest is 19.2.7, published June 1, 2026; 19.3 exists only as canary builds.

  2. React 19.2 release post (October 1, 2025)<Activity /> shipped as a stable API in 19.2 alongside useEffectEvent and cacheSignal. 2

  3. <Activity> — official React reference — props, hidden-mode semantics ("destroy their Effects", "lower priority", display: "none"), pre-rendering, the Suspense-enabled data source note, Selective Hydration, and the troubleshooting entries. Fetched June 12, 2026. 2 3 4 5 6 7 8 9 10 11 12 13 14 15