Skip to solution
hardFrontend

How do you keep the UI responsive during an expensive update without a Suspense fallback flash?

269 views
01

Understand the problem

Question presented to candidate: "Switching tabs replaces the content with a spinner for a moment, and typing in the filter box stutters. How do you fix both without changing the data layer?"

What a strong answer should cover:

  • Two different problems that share a solution: the fallback flash (Suspense replacing visible content) and input lag (an expensive render blocking the keystroke).
  • Wrapping the update in startTransition marks it non-urgent, and React then keeps the already-visible content on screen instead of showing the Suspense fallback.
  • That only applies to content that has already been shown. A first load has nothing to keep, so the fallback still appears — which is correct.
  • isPending gives you the affordance: dim the stale content, disable the control. Not a spinner replacing it, which recreates the flash you removed.
  • For a value arriving from elsewhere — a prop, a controlled input — useDeferredValue is the same idea applied to a value you consume rather than an update you make.
  • Deferring only helps if the expensive child is memoised, or both renders do the work.
  • These reprioritise rendering. If the wait is the network, the fix is fetching earlier — preloading or hoisting the request.

Clarifying questions expected:

  • "Is the delay rendering or fetching?" — transitions help the first only.
  • "Is there already content on screen, or is this a first load?" — that decides whether the fallback is avoidable at all.

Code / implementation expected: Optional. Showing the same update with and without startTransition is the whole answer.

reactconcurrentsuspense
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 Suspense and transitions. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The two runs in section 3 were executed against React 19.2.8

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Tab switching with and without a transition — watch the fallback
Run Playground
import { useState, useTransition, Suspense, use } from "react";

// A tiny cache so each tab's promise is created ONCE and reused. Creating a
// promise during render would never settle — the promise must outlive the render.
const cache = new Map();
function loadTab(name) {
  const key = name;
  if (!cache.has(key)) {
    cache.set(key, new Promise((r) => setTimeout(() => r(name), 900)));
  }
  return cache.get(key);
}

function TabPanel({ tab }) {
  const loaded = use(loadTab(tab));
  return (
    <div style={{ padding: 12 }}>
      <strong style={{ fontSize: 14 }}>{loaded}</strong>
      <p style={{ fontSize: 13, color: "#555", margin: "6px 0 0" }}>
        Content for the {loaded} tab. Once you have seen this, a transition can
        keep it on screen while the next tab loads.
      </p>
    </div>
  );
}

function Skeleton() {
  return (
    <div style={{ padding: 12 }}>
      <div style={{ background: "#e4e4e7", height: 16, width: 120, borderRadius: 4 }} />
      <div style={{ background: "#eee", height: 12, width: "90%", borderRadius: 4, marginTop: 8 }} />
      <div style={{ background: "#eee", height: 12, width: "70%", borderRadius: 4, marginTop: 6 }} />
      <div style={{ fontSize: 12, color: "#a33", marginTop: 8 }}>← the fallback is showing</div>
    </div>
  );
}

const TABS = ["Posts", "Photos", "Albums"];

export default function App() {
  const [tab, setTab] = useState("Posts");
  const [useTransitionMode, setMode] = useState(true);
  const [isPending, startTransition] = useTransition();

  const select = (t) => {
    if (useTransitionMode) startTransition(() => setTab(t));
    else setTab(t);                       // urgent: React must show something now
  };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 520 }}>
      <label style={{ fontSize: 13, display: "block", marginBottom: 10 }}>
        <input
          type="checkbox"
          checked={useTransitionMode}
          onChange={(e) => setMode(e.target.checked)}
        />
        {" "}wrap the update in <code>startTransition</code>
      </label>

      {/* isPending drives a LIGHT affordance — dimming, not a replacement. */}
      <nav style={{ display: "flex", gap: 6, opacity: isPending ? 0.55 : 1, transition: "opacity 120ms" }}>
        {TABS.map((t) => (
          <button
            key={t}
            onClick={() => select(t)}
            disabled={isPending}
            style={{ fontWeight: t === tab ? "bold" : "normal" }}
          >
            {t}
          </button>
        ))}
        {isPending && <span style={{ fontSize: 12, color: "#666", alignSelf: "center" }}>loading…</span>}
      </nav>

      <div style={{ border: "1px solid #ddd", borderRadius: 8, marginTop: 10, minHeight: 110,
                    opacity: isPending ? 0.55 : 1, transition: "opacity 120ms" }}>
        <Suspense fallback={<Skeleton />}>
          <TabPanel tab={tab} />
        </Suspense>
      </div>

      <p style={{ fontSize: 13, color: "#666" }}>
        The very first tab shows the skeleton either way — there is nothing to
        keep. After that, switch tabs with the box ticked: the previous content
        stays, dimmed, and swaps in when ready. Untick it and the same switch
        blanks the panel to the skeleton every time. Tabs you have already
        visited are cached, so use one you have not seen yet.
      </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 111 of 119 decoded in the React.js track. One more won't hurt.

Back to track