---
title: 'React Activity Component Tutorial: Stateful Tabs (2026)'
description: 'Hands-on React Activity component tutorial: preserve tab state with hidden mode, pause effects, and pre-render hidden UI. Runnable React 19.2 code, 2026.'
url: https://nerdleveltech.com/react-activity-component-tutorial
language: en
date: '2026-06-12'
category: web-frontend
tags:
  - react
  - activity
  - react-19
  - tabs
  - state-management
  - performance
  - typescript
  - tutorial
  - web-frontend
markdown_url: https://nerdleveltech.com/react-activity-component-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/react-activity-component-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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)[^npm]. `Activity` is a stable top-level export in 19.2; there is no `unstable_` prefix anymore[^react192]
- 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](https://nerdleveltech.com/react-useeffect-2025) first

Set up a scratch project:

```bash
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`:

```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`:

```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`:

```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:

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

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

```tsx
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:

```tsx
<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:

```tsx
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 Effects[^docs].

The change from Step 2 is three lines:

```tsx
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 omitted[^docs]. Two executed tests confirm all three promises. First, Effects cleaned up on hide and re-created on reveal:

```tsx
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:

```tsx
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 position[^docs]. 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 running[^docs].

## 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 Effects[^docs]. 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 times[^docs].

```tsx
<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:

```tsx
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 Effect[^docs]. 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 hide[^docs]. If you're on the App Router, this composes with the streaming patterns from our [Next.js 16 streaming and `use cache` tutorial](https://nerdleveltech.com/nextjs-16-streaming-suspense-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 state | destroyed | kept | **kept** |
| DOM state (e.g. an input's draft) | destroyed | kept | **kept** |
| Effects / subscriptions | cleaned up | **still running** | cleaned up |
| Re-renders on prop changes | no (not mounted) | yes, full priority | yes, lower priority |
| Can pre-render unseen content | no | renders eagerly, Effects and all | **yes, 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 updates[^docs]. The executed render-log test confirms a hidden child receives new prop values:

```tsx
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:

```bash
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 Transition[^docs]:

```tsx
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` to[^docs]. 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 instead[^docs].

**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 effects[^docs]. The same Effect hygiene that fixes reconnect churn in our [useEffectEvent tutorial](https://nerdleveltech.com/react-useeffectevent-stop-effect-reruns-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 exit[^docs]. Second, audit your Effect hygiene with our [React useEffectEvent tutorial](https://nerdleveltech.com/react-useeffectevent-stop-effect-reruns-tutorial) — Activity assumes cleanups are correct, and effect events keep them small. For the broader 19.2 surface, the [official release post](https://react.dev/blog/2025/10/01/react-19-2) covers what shipped alongside Activity[^react192].

[^docs]: [`<Activity>` — official React reference](https://react.dev/reference/react/Activity) — 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.
[^react192]: [React 19.2 release post (October 1, 2025)](https://react.dev/blog/2025/10/01/react-19-2) — `<Activity />` shipped as a stable API in 19.2 alongside `useEffectEvent` and `cacheSignal`.
[^npm]: 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.
