Skip to solution
hardFrontend

The Activity API — hiding UI without unmounting in React 19

566 views
01

Understand the problem

Question presented to candidate: "A tab panel loses its scroll position and form input every time the user switches away. How would you keep it without keeping it mounted and running?"

What a strong answer should cover:

  • <Activity mode="hidden"> hides a subtree while preserving its state. React keeps the component's state and DOM but unmounts its effects.
  • That is the key distinction and the whole reason it exists: state survives, effects do not. Timers stop, subscriptions unsubscribe, observers disconnect — but the input value, scroll position and local state are all still there when it returns.
  • The alternatives are both worse: a conditional unmount destroys state, and CSS display: none keeps everything running — timers, subscriptions, polling.
  • Returning to mode="visible" restores the state and re-runs the effects, exactly like a remount for effect purposes.
  • Because effects tear down and set up again, this only works if your effects are idempotent — the same property StrictMode tests for.
  • React can also use hidden Activity boundaries to pre-render content at low priority, so it is ready before the user asks for it.
  • Typical uses: tab panels, multi-step wizards, a route the user is likely to go back to, an off-screen detail pane.

Clarifying questions expected:

  • "Should the hidden panel keep polling, or stop?" — Activity stops it; display: none does not.
  • "How much state is there to lose?" — a single scroll position may not justify it.

Code / implementation expected: Optional. The three-way comparison against a conditional and display: none is what makes it land.

activityreact-19
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for senior React interviews — assumes the component lifecycle. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Section 3 is executed against React 19.2.8, including the contra

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Activity against a conditional and display:none — same panel, three behaviours
Run Playground
import { useState, useEffect, useRef, Activity } from "react";

// A panel with BOTH kinds of state: React state, uncontrolled DOM state, and
// an effect doing ongoing work. Each strategy treats these differently.
function Panel({ label, onTick }) {
  const [count, setCount] = useState(0);
  const ticks = useRef(0);

  useEffect(() => {
    // Ongoing work — exactly what should stop when nobody is looking.
    const id = setInterval(() => { ticks.current++; onTick(label); }, 500);
    return () => clearInterval(id);
  }, [label, onTick]);

  return (
    <div style={{ border: "1px solid #ccd", borderRadius: 6, padding: 10, background: "#fafaff" }}>
      <div style={{ fontSize: 13 }}>
        React state: <strong>{count}</strong>{" "}
        <button onClick={() => setCount((c) => c + 1)}>+1</button>
      </div>
      <input placeholder="type something (uncontrolled)" style={{ width: "100%", marginTop: 6 }} />
    </div>
  );
}

export default function App() {
  const [strategy, setStrategy] = useState("activity");
  const [shown, setShown] = useState(true);
  const timers = useRef({});
  const [, refresh] = useState(0);

  // Counted in a ref so counting never causes a render of its own.
  const onTick = (label) => { timers.current[label] = (timers.current[label] || 0) + 1; };

  const panel = <Panel label={strategy} onTick={onTick} />;

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 540 }}>
      <div style={{ display: "flex", gap: 6, marginBottom: 10, flexWrap: "wrap" }}>
        {[["activity", "✅ Activity"], ["conditional", "❌ conditional"], ["css", "⚠️ display:none"]].map(([k, l]) => (
          <button key={k} onClick={() => { setStrategy(k); setShown(true); timers.current = {}; }}
                  style={{ fontWeight: strategy === k ? "bold" : "normal" }}>
            {l}
          </button>
        ))}
        <button onClick={() => setShown((s) => !s)}>{shown ? "hide" : "show"}</button>
        <button onClick={() => refresh((n) => n + 1)}>refresh counts</button>
      </div>

      <div style={{ minHeight: 96 }}>
        {strategy === "activity" && (
          // State AND DOM preserved; effects unmounted while hidden.
          <Activity mode={shown ? "visible" : "hidden"}>{panel}</Activity>
        )}
        {strategy === "conditional" && (
          // Everything destroyed and rebuilt.
          (shown ? panel : <em style={{ fontSize: 13, color: "#999" }}>(unmounted)</em>)
        )}
        {strategy === "css" && (
          // Everything kept — including the interval, still firing.
          <div style={{ display: shown ? "block" : "none" }}>{panel}</div>
        )}
        {!shown && strategy !== "conditional" && (
          <em style={{ fontSize: 13, color: "#999" }}>(hidden)</em>
        )}
      </div>

      <p style={{ fontSize: 13, color: "#666", marginTop: 10 }}>
        interval ticks recorded for <code>{strategy}</code>:{" "}
        <strong>{timers.current[strategy] || 0}</strong>
      </p>

      <p style={{ fontSize: 13, color: "#666" }}>
        Try each: click +1 a few times, type in the box, hide, wait a couple of
        seconds, then show and press refresh. <strong>Activity</strong> returns
        your count and your typing with the tick counter frozen while hidden.{" "}
        <strong>Conditional</strong> returns everything at zero and empty.{" "}
        <strong>display:none</strong> returns everything intact — and the tick
        counter kept climbing the whole time nobody was looking.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 97 of 119 decoded in the React.js track. One more won't hurt.

Back to track