Skip to the solution
HardFrontend7 min read

The Activity API — hiding UI without unmounting in React 19

566 views

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.

  • activity
  • react-19

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read · 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactActivity against a conditional and display:none — same panel, three behaviours
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>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 97 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

Concurrent Rendering — useDeferredValue and time slicing in React 19

Continue