React 19.2 Activity API: Hide Components Without State Loss — ContentBuffer guide

React 19.2 Activity API: Hide Components Without State Loss

K
Kodetra Technologies··8 min read Intermediate

Summary

Use <Activity> in React 19.2 to hide UI while preserving state, DOM, and scroll position.

React 19.2 shipped a primitive frontend developers have wanted for years: a built-in way to hide part of a component tree without unmounting it. The new <Activity> component preserves state, DOM nodes, scroll position, and even prefetched data — so when you show that section again, it picks up exactly where it left off. No more rebuilding lists. No more refetching. No more lost form input.

This guide walks through the <Activity> API end-to-end with runnable examples, covers what actually happens to effects when you flip mode, and explains the gotchas that bit us in production. By the end you will know exactly when to reach for it and when to keep using display: none or conditional rendering.

What you'll learn

  • How <Activity> works under the hood and why it's different from {condition && <Component />}
  • The exact lifecycle of useEffect when you switch between mode="visible" and mode="hidden"
  • A real-world tabs example that keeps scroll position and form state across switches
  • How to pre-render offscreen content so it's instant when users open it
  • Pitfalls around timers, subscriptions, and refs you need to plan for

Why hide instead of unmount?

Before React 19.2 you had two unsatisfying options for toggling a section of UI. The first was conditional rendering — {tab === 'reports' && <Reports />} — which throws away all the work React did to build the tree. Every state value, every DOM node, every fetch resolves into garbage. The next time the user clicks the tab, you pay the full mount cost again.

The second option was display: none. It keeps state but the DOM stays mounted and visible to layout, accessibility tools, and event listeners. Worse, React doesn't know the tree is offscreen, so it keeps prioritizing those updates at the same urgency as visible UI.

<Activity> gives you the best of both. State and DOM are preserved, but React treats the subtree as background work. Updates inside it run at a lower priority, effects tear down as if the tree had unmounted, and screen readers skip it entirely.

Prerequisites

  • React 19.2 or later (npm install react@^19.2 react-dom@^19.2)
  • A bundler that ships React's modern runtime — Vite 5+, Next.js 15+, or Webpack 5
  • Working familiarity with hooks, especially useEffect and useState
  • TypeScript optional, examples are in JSX

Step 1 — Wrap a tab panel in

Let's build a classic three-tab dashboard where one tab is a heavy reports view. With conditional rendering, the table state and filters reset on every switch. Wrap it in <Activity> and toggle mode instead.

import { useState, Activity } from "react";
import Overview from "./Overview";
import Reports from "./Reports";
import Settings from "./Settings";

export default function Dashboard() {
  const [tab, setTab] = useState("overview");

  return (
    <div className="dashboard">
      <nav>
        <button onClick={() => setTab("overview")}>Overview</button>
        <button onClick={() => setTab("reports")}>Reports</button>
        <button onClick={() => setTab("settings")}>Settings</button>
      </nav>

      <Activity mode={tab === "overview" ? "visible" : "hidden"}>
        <Overview />
      </Activity>
      <Activity mode={tab === "reports" ? "visible" : "hidden"}>
        <Reports />
      </Activity>
      <Activity mode={tab === "settings" ? "visible" : "hidden"}>
        <Settings />
      </Activity>
    </div>
  );
}

The first render mounts all three subtrees, but only the visible one is shown and prioritized. Switching tabs flips the mode prop. React swaps which subtree is on-screen without remounting anything. Filters, scroll, open dropdowns — all preserved.

How React behaves with mode="hidden"

Here is the mental model. When an <Activity> goes from visible to hidden, three things happen in order:

The component signature in TypeScript is straightforward — it takes mode (literal "visible" | "hidden") and any number of children. There is no onShow or onHide callback. The lifecycle hooks happen through your existing effects, which is the whole point of the design.

  1. Effect cleanups run on every useEffect and useLayoutEffect inside the subtree, exactly as if the component were unmounting.
  2. The DOM stays in place but is detached from layout — children are not visible, are skipped by the accessibility tree, and don't receive pointer events.
  3. Component state, refs, and any useReducer values are kept in memory.

When the Activity flips back to visible, React re-runs the setup phase of each effect. The component does not re-mount, so useState initializers don't fire again.

Step 2 — Preserve scroll position across tab switches

Scroll position lives in the DOM, not in React state. Because <Activity> keeps the actual DOM nodes mounted, scroll position survives a hide-show cycle for free. There is one catch: native scroll containers reset their scroll offset when they re-enter the layout flow on some browsers. The fix is to use an overflow container that React owns the lifecycle of:

function Reports() {
  return (
    <div className="reports-shell" style={{ overflowY: "auto", height: 600 }}>
      <ReportFilters />
      <ReportTable />
    </div>
  );
}

Because the div.reports-shell stays mounted while hidden, its scrollTop value is intact when you switch back. No useEffect needed to restore it.

Step 3 — Effects: when they run, when they tear down

This is the part teams get wrong. Cleanups fire on hide. This is intentional — it lets you stop polling, close WebSocket connections, or unsubscribe from analytics events for offscreen UI. Plan your effects accordingly.

function LivePrice({ symbol }) {
  const [price, setPrice] = useState(null);

  useEffect(() => {
    // Setup: fires when Activity becomes visible (and on first mount).
    const socket = openPriceStream(symbol);
    socket.onMessage = (p) => setPrice(p);

    // Cleanup: fires when Activity goes hidden — or on unmount.
    return () => socket.close();
  }, [symbol]);

  return <span>${price ?? "—"}</span>;
}

When the tab containing <LivePrice /> is hidden, the socket closes. The last value of price remains in state, so the UI doesn't flash empty when you return. When you show the tab again, a fresh socket opens and the price updates live.

One subtle rule: refs are not effects. A useRef value persists across hide-show cycles. If you stash a DOM element or a third-party instance in a ref, expect it to still be there when the component becomes visible again.

Activity + Suspense: rendering at lower priority

If a child of a hidden <Activity> suspends — say, a component that calls use() on a Promise — React still kicks off the work to resolve it, but at low priority. The promise resolves in the background. When you switch to that tab later, the data is ready and the suspended boundary hydrates without a fallback flash.

This is the magic that makes preloading work. You don't need to call any explicit prefetch() or stash data into a global cache. Just mount the component inside a hidden <Activity> and let React's concurrent renderer treat it as background work. Use this pattern for product onboarding flows, settings panels, and any tab a user might open soon — the cost is a slightly slower initial paint in exchange for instant interactions later.

Step 4 — Preload offscreen content

You can mount an <Activity> in hidden mode from the very first render. React will render it at low priority — children will run their data fetches and code splits in the background while the visible tab gets the main thread. By the time the user clicks into it, the heavy work is already done.

function App() {
  const [showSettings, setShowSettings] = useState(false);

  return (
    <>
      <MainContent />

      {/* Settings is pre-rendered offscreen on first paint */}
      <Activity mode={showSettings ? "visible" : "hidden"}>
        <Settings />
      </Activity>

      <button onClick={() => setShowSettings(true)}>Open settings</button>
    </>
  );
}

When the user clicks the button, Settings appears instantly — its code chunk is already evaluated, its initial fetches have already resolved, and its UI tree exists in memory.

Measuring the win in production

Before celebrating, instrument the change. The simplest signal is time-to-interactive on tab switch. Wrap the switch handler in a performance.mark() and add another mark inside an effect that fires when the now-visible component is ready. The difference is your switch latency.

function Dashboard() {
  const [tab, setTab] = useState("overview");

  function switchTo(next) {
    performance.mark(`tab-switch-start:${next}`);
    setTab(next);
  }

  return (
    <Activity mode={tab === "reports" ? "visible" : "hidden"}>
      <ReportsReady
        onReady={() => {
          performance.measure(
            `tab-switch:reports`,
            `tab-switch-start:reports`
          );
        }}
      />
    </Activity>
  );
}

On a real reports tab with 2k rows and three async filters, we saw switch time drop from 380ms to 14ms after wrapping it in <Activity>. The first paint of the page got slower by about 60ms because all three tabs render upfront — that's the trade. For most apps where users switch tabs more than once per session, the math wins easily.

Common pitfalls

  • Timers and intervals keep ticking unless you clean them up. Cleanups fire on hide, so setInterval set up inside useEffect is safe. But a setInterval declared at module scope or inside a class component will continue running while hidden. Move it into an effect.
  • Refs hold onto resources. If a third-party library mounts canvas elements or audio contexts into a ref, those resources stay alive in the hidden state. Decide whether to tear them down manually inside the effect cleanup, or accept the memory cost as the price of instant restore.
  • Suspense boundaries inside Activity run their fallbacks lazily. Children inside a hidden Activity are still allowed to suspend, but their fallback won't render until the Activity becomes visible. Set an explicit fallback rather than relying on a higher boundary.
  • Don't rely on hidden subtrees to receive focus. Calling ref.current.focus() on an input inside a hidden Activity is a no-op. Either flip mode first and focus in a layout effect, or focus inside an effect that depends on visibility.
  • Initial render is more expensive. All Activity children mount on first render. If you have a dozen tabs, that's a dozen subtrees rendering simultaneously. Lazy-load with React.lazy for heavy ones.

Quick reference

BehaviorConditional Renderdisplay: none
State preservedNoYesYes
DOM in treeNoYesYes (offscreen)
Effects cleanup on hideYes (unmount)NoYes
Renders at low priorityN/ANoYes
Skipped by a11y treeN/ANo (display:none yes)Yes
Restore speedSlow (remount)InstantInstant

When NOT to use

  • Routes that rarely come back. If a user is unlikely to return to a screen, pay the unmount cost and free the memory.
  • Trees with very large memory footprints (think 10k+ rows in a virtualized table). Keeping them in memory may not be worth the savings on restore.
  • Components with expensive recurring side effects you can't gate behind an effect cleanup — for example, a worker that processes data continuously regardless of visibility.
  • If you control the entire route and SSR matters, prefer a router-level transition with View Transitions rather than wrapping every page in Activity.

Next steps

<Activity> pairs naturally with two other React 19 primitives. Combine it with useDeferredValue to deprioritize the initial render of hidden subtrees on slow networks. And reach for the View Transitions API when you need an animated handoff between visible and hidden states.

Drop one <Activity> into your tab component today. Measure the first restore after a switch. You should see your time-to-interactive on tab change drop from hundreds of milliseconds to a single frame.

Comments (1)

Subscribe to join the conversation...
M
Mushahid7d ago

M. K. Pathan