Skip to the solution
HardFrontend8 min read

What is "tearing" in concurrent React?

439 views

The problem

Question presented to candidate: "We migrated to React 18 and turned on transitions. Now, occasionally, two parts of the same screen show different values for the same piece of global state. What is happening, and how do you fix it?"

What a strong answer should cover:

  • Tearing: a single committed screen displaying two different values of the same source of truth.
  • Why React 18 made it possible: concurrent rendering can interrupt and resume a render, so a mutable external value can change mid-render.
  • Why React's own state is immune β€” it is snapshotted per render β€” while an external store read directly in render is not.
  • useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) as the official fix, and that it deliberately forces a synchronous, non-interruptible re-render.
  • getSnapshot must return a cached value; returning a fresh object each call is an infinite loop.
  • getServerSnapshot exists for SSR, where there is no store to subscribe to.
  • That modern libraries (Redux, Zustand, Jotai) already call this internally β€” you rarely write it by hand.

Clarifying questions expected:

  • "Is the state in a React store or an external one β€” a module variable, a Redux store, a browser API?"
  • "Are we using transitions or Suspense anywhere near this subtree?"

Code / implementation expected: Yes β€” a useSyncExternalStore call with a correct, cached getSnapshot.

  • react
  • concurrent
  • state-management

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

8 min read Β· 9 sections

Try it in the playground

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

ReactuseSyncExternalStore with a correctly cached snapshot
import { useSyncExternalStore, startTransition, useState } from "react";

// A tiny external store β€” mutable state living entirely outside React.
function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    // The snapshot is the state object itself, replaced only on write. This is
    // what makes getSnapshot cacheable: same data means the SAME reference.
    getSnapshot: () => state,
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    set: (next) => {
      state = next;                 // new object -> Object.is sees a change
      listeners.forEach((l) => l());
    },
  };
}

const store = createStore({ count: 0 });

// WRONG, for contrast β€” a fresh object every call makes Object.is always false,
// so React re-renders forever:
//   const bad = () => ({ count: store.getSnapshot().count });

function Panel({ name }) {
  // The third argument is required for server rendering: on the server there is
  // nothing to subscribe to, so React needs a value it can render into HTML.
  // Omit it and server rendering throws "Missing getServerSnapshot".
  const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
  return (
    <div style={{ border: "1px solid #ccc", borderRadius: 6, padding: 12, minWidth: 120 }}>
      <div style={{ fontSize: 12, color: "#666" }}>{name}</div>
      <strong style={{ fontSize: 22 }}>{snap.count}</strong>
    </div>
  );
}

export default function App() {
  const [, force] = useState(0);
  const bump = () => store.set({ count: store.getSnapshot().count + 1 });

  return (
    <div style={{ padding: 24, fontFamily: "system-ui" }}>
      <div style={{ display: "flex", gap: 12 }}>
        <Panel name="Panel A" />
        <Panel name="Panel B" />
      </div>
      <p>
        <button onClick={bump}>Increment store</button>{" "}
        {/* Even inside a transition, this update commits synchronously and
            both panels always agree β€” that is the anti-tearing guarantee. */}
        <button onClick={() => startTransition(bump)}>Increment in a transition</button>{" "}
        <button onClick={() => force((n) => n + 1)}>Re-render parent</button>
      </p>
      <p style={{ color: "#666", fontSize: 13 }}>
        Both panels read the same store independently and can never disagree,
        because React re-checks the snapshot before it commits.
      </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 103 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

When does `React.memo` do nothing β€” or actually hurt performance?

Continue