Articles
React External Store Interview Questions: Snapshots, Subscriptions, and Hydration
Practice React external store interview questions with stable snapshots, subscription cleanup, SSR hydration contracts, and a small tested store implementation.
A React external-store interview tests a consistency contract: repeated reads of unchanged data must return a stable snapshot, updates must publish a new snapshot before notifying subscribers, and server rendering must agree with the client's initial hydration view. useSyncExternalStore connects that contract to React; it cannot repair arbitrary mutation or a store accidentally shared between users.
Official React sources establish hook behavior. The counter store, failure experiments, and hydration trace below are original exercises, not employer-specific question reports.
What makes a store external to React?
A store is external when its state and updates live outside React's ordinary state ownership. Examples include a library's mutable store, a browser API, or an application object that receives network events. “External” does not necessarily mean remote or shared across processes.
For this original exercise, a counter lives in a plain JavaScript closure. React components can observe it, but a non-React callback can also change it. That makes the boundary easy to inspect without introducing a network protocol or a state-management library.
React introduced useSyncExternalStore to support external subscriptions with concurrent rendering. The release guidance positions it particularly for library integrations; an ordinary component does not need an external store merely because it has several fields. React 18 release overview
Start the interview by explaining why the state must be external. If one component owns everything and no outside system needs access, regular React state may be simpler. Choosing an integration mechanism without identifying an integration problem adds work without proving judgment.
What are the three functions in the contract?
The hook receives subscribe, getSnapshot, and optionally getServerSnapshot. Subscription returns a cleanup function. Snapshot reads must be stable while data is unchanged, with immutable values that change when the represented state changes. React compares snapshots using Object.is. The server getter supplies the initial server-rendering and hydration view. useSyncExternalStore reference
A notification says “read the current snapshot again”; it need not carry the state itself. That distinction lets the consumer determine whether the snapshot relevant to it has changed. For the counter, a no-op assignment should preserve the existing object and avoid notifying anyone.
Keep these functions stable for a stable store instance. Reconstructing a subscribe wrapper inside every render can force unnecessary subscription replacement. More seriously, constructing the entire store inside every render can reset its data and strand outside producers with references to an older instance.
In our design, the store is created at an explicit application boundary and passed into the component. A parent can keep that instance in an appropriate stable location. On the server, the boundary must be request-scoped when state contains request-specific data.
Can you build a small immutable snapshot store?
This original implementation limits state to one safe integer. That deliberate constraint makes Object.freeze sufficient for the snapshot's data: there are no nested mutable objects. It publishes the replacement before sending synchronous notifications.
export function createCounterStore(initialCount = 0) {
if (!Number.isSafeInteger(initialCount)) {
throw new TypeError('count must be a safe integer');
}
let snapshot = Object.freeze({ count: initialCount });
const initialSnapshot = snapshot;
const listeners = new Set();
return {
getSnapshot: () => snapshot,
getServerSnapshot: () => initialSnapshot,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
setCount(nextCount) {
if (!Number.isSafeInteger(nextCount)) {
throw new TypeError('count must be a safe integer');
}
if (nextCount === snapshot.count) return;
snapshot = Object.freeze({ count: nextCount });
for (const listener of [...listeners]) listener();
}
};
}
Copying the listener set defines a simple delivery rule: changes to membership during one notification do not change that iteration's recipient list. A callback removed halfway through may still run in the current round. Its removal prevents later notifications. Document that behavior rather than leaving it as an accidental consequence of iteration.
This small store assumes subscribers do not throw or synchronously trigger recursive updates. A general event library needs an explicit policy for those cases. The counter is an integration exercise, not a claim to implement every production store requirement.
The server snapshot is captured separately from the live snapshot. If the live value changes before hydration finishes, the initial getter can still reproduce the server-rendered value. After the initial hydration view, React can observe the newer live snapshot through the normal getter.
Here is the consumer, assuming store is a stable instance supplied by the caller:
function Counter({ store }) {
const state = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getServerSnapshot
);
return <output>{state.count}</output>;
}
Import useSyncExternalStore from React in the consuming module. Do not add a second local copy of state.count unless a distinct local editing workflow requires it; two copies introduce another synchronization problem.
Why do both fresh objects and in-place mutation fail?
Try replacing getSnapshot with () => ({ count: snapshot.count }). Every call now returns a different object, including calls made without any store update. The hook cannot use identity to distinguish a real change from a read. React documents the resulting cached-snapshot error. Snapshot troubleshooting
The opposite mutation is equally instructive: retain one object, edit its count field, and notify. The object identity stays unchanged while its contents change. A consumer holding the old reference can observe its meaning changing underneath it. Our frozen snapshot deliberately prevents that in the example.
Write two independent assertions. First, two reads without an update should return the same reference. Second, a meaningful update should return a different reference while leaving the previous snapshot's value unchanged. Testing only the visible count misses the historical-reference invariant.
Do not generalize shallow freezing to arbitrary nested state. If a snapshot contains an array of mutable records, freezing only its outer wrapper still leaves those records mutable. Use a representation that replaces changed paths or produces a cached immutable projection of the underlying store.
Why is an Effect subscription not an equivalent replacement?
A hand-built pattern often reads the store during render and installs a listener in an Effect. The interesting failure is the interval between those operations: the store can change after the read but before the subscription is established. A design that only waits for the next notification can remain stale.
React's external-store RFC explains the concurrency motivation and the need for consistent store reads. The hook is designed for this integration problem rather than treating subscriptions as an ordinary after-render side effect. React external-store RFC
In an interview, draw the sequence rather than saying “Effects are bad.” Read version one, publish version two, install the listener, then stop publishing. Ask which operation causes the component to discover version two. That question exposes the missed-update gap in the naive design.
Effects remain appropriate for synchronizing with many external systems. Their setup and cleanup must mirror each other, and development Strict Mode can run an extra setup-cleanup cycle to reveal lifecycle defects. React useEffect documentation
Test mount, cleanup, and resubscribe behavior without relying on a specific development render count as a correctness guarantee.
How do you make hydration start from the same state?
Suppose the server renders count seven. The browser's first hydration view must represent that same count; reading a browser-only persisted value of nine immediately can mismatch the existing HTML. React's hydration API expects the client output to match the server-rendered content and treats mismatches as bugs to fix. hydrateRoot documentation
Use the server's initial data to create the client store. The same object reference cannot cross the network, but the represented initial data must agree. A safe serialization mechanism belongs to the application framework or a deliberately escaped data-transfer path; do not interpolate untrusted JSON into executable script text casually.
| Moment | Initial getter | Live getter | Expected displayed state |
|---|---|---|---|
| Server render | Count seven | Count seven | Seven |
| Browser store created from transferred data | Count seven | Count seven | Existing server HTML |
| A live update arrives before hydration finishes | Count seven | Count nine | Hydration still starts from seven |
| Normal client observation | Retained initial value | Count nine | Update to nine |
The table is our exercise's intended sequence, not a guarantee about every scheduling detail. Test it with a real hydration integration and collect recoverable errors. A pure store unit test cannot prove that the server markup and client renderer agree.
Never use one module-level mutable server store for unrelated authenticated requests. A request-scoped factory prevents one user's initial data from becoming another user's server snapshot. Client singleton convenience is not a reason to reuse the same ownership boundary on the server.
What should happen when only an unrelated field changes?
Expand the exercise mentally to { count, theme }. A component that receives the entire snapshot observes a new object when either field changes. That is correct but may do more work than necessary. Decide whether it should subscribe to a narrower representation.
A primitive count projection has a straightforward stable identity. An object projection that rebuilds { count } on every read recreates the earlier fresh-object problem. Memoization or a selector-aware integration must preserve the equality contract for the selected value.
Measure before inventing a complicated selector cache. If a small number of inexpensive components rerender, the extra machinery may cost more than it saves. If a high-frequency feed updates thousands of unrelated consumers, the subscription and selection boundary becomes a concrete performance concern.
Keep state consistency separate from event delivery. A snapshot consumer generally needs the latest valid state; it may not render every intermediate event. If every event matters for an audit trail or animation sequence, retain an event log or queue with its own contract rather than assuming one rendered frame per notification.
Which tests should you demonstrate?
Start with the store alone: stable reads, changed identity after a real update, unchanged historical snapshot, no notification for an identical count, and no notifications after cleanup. Verify invalid input leaves both state and subscription behavior unchanged.
Then test two store instances initialized with different values. Updating one must not change the other. This is a small but important ownership test for the request-scoped design. Test listener removal during notification according to the delivery rule you documented.
For the React integration, server-render a Counter at seven, hydrate with an equivalent initial store, and assert the displayed value plus absence of recoverable hydration errors. Update the live store to nine and assert the DOM changes. Repeat with the live update occurring before hydration to exercise the separate initial getter.
Finally, be precise about what passed. These checks establish the demonstrated state and hydration paths. They do not exhaust every concurrent scheduler interleaving, browser environment, or third-party store implementation. A strong answer connects each test to the invariant it checks and names the remaining boundary.