useSyncExternalStore as the sanctioned escape hatch
React ships one supported way to read a mutable external store during render, and its cached-snapshot contract is not optional — get it wrong and you trade dropped frames for an infinite render loop.
The last lesson left you with two copies of the same text. The ref holds the truth, updated on every chunk. The state holds a snapshot of it, updated on a tick. Everything renders from the snapshot, and the obvious question follows: why not read the buffer directly?
Because a render that reads an untracked mutable value is unsound, and React knows this comes up often enough to ship a supported way to do it. One hook, three arguments, and a contract that is not optional.
React tells you not to reach for this first
Start with the sentence people skip. The reference recommends preferring useState and useReducer, and reaching for this hook only when you need to integrate with existing non-React code. It is an integration hook, not a performance hook.
Your case qualifies, and it is worth being precise about why. A token buffer that a network handler mutates outside React’s knowledge is existing non-React state. It is not React state that you have decided to hide in a ref for speed. If you cannot say that sentence honestly about your own store, use useState.
Where people get burned
This hook does not avoid re-renders. It is not a faster useState, and adopting it will not by itself move a number. What it buys you is a supported way to read a mutable external value during render without tearing. The render reduction in this module comes from the flush schedule you chose in the last lesson, and it still does. Anyone selling this hook as an optimisation has skipped the first paragraph of its own documentation.
The three arguments
const listeners = new Set()
let snapshot = '' // the cached value getSnapshot returns
let buffer = '' // the mutable accumulator
function subscribe(callback) {
listeners.add(callback)
return () => listeners.delete(callback)
}
function getSnapshot() {
return snapshot // must be Object.is-stable when nothing changed
}
function flush() {
if (buffer === snapshot) return
snapshot = buffer // publish a new value, once
listeners.forEach((l) => l())
}
// in the component
const text = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)subscribe registers a callback React will invoke when the store changes, and returns a function that unregisters it. getSnapshot reads the current value. getServerSnapshot supplies the value used during server-rendering and hydration, and is required if your surface renders on the server at all.
Notice where the throttle now lives. Chunks still append to buffer with no notification. flush is what publishes, and calling it once a frame gives you the same decoupling as the previous lesson, with the tree reading the store rather than a copy of it.
The contract, and the exact way it breaks
getSnapshot must return the same value while the store has not changed, and React compares with Object.is. The reference is explicit that the snapshot must be immutable and that a cached last snapshot should be returned when the data has not changed.
Get it wrong and the failure is not subtle. React names it in its own error text: the result of getSnapshot should be cached. What happens underneath is an infinite loop. React calls getSnapshot, gets a value that is not Object.is-equal to the last one, concludes the store changed, re-renders, calls getSnapshot again, and gets another new value.
// Wrong: a new object every call, so Object.is always fails
function getSnapshot() {
return { text: buffer, length: buffer.length }
}
// Right: publish a new object only when the data actually changed
let snapshot = { text: '', length: 0 }
function flush() {
if (buffer === snapshot.text) return
snapshot = { text: buffer, length: buffer.length }
listeners.forEach((l) => l())
}
function getSnapshot() {
return snapshot
}The wrong version looks correct in review. It is a pure function of the buffer, it has no side effects, and it reads exactly like the kind of selector people write everywhere else. That is what makes this the one rule to internalise: the snapshot is a value you publish, not a value you compute on demand. Anything derived — a message count, a formatted string, a sliced array — is derived at publish time, once, or in the component from a stable snapshot.
The caveat that hands off to the next lesson
One more line from the reference matters for what comes next. If the store is mutated during a non-blocking Transition update, React falls back to performing that update as blocking: it re-reads getSnapshot before committing, and if the value changed since the render started, it restarts the update as a blocking one.
Read that against a stream. A store that is mutated continuously is a store that is very likely to have changed mid-render. Marking your transcript update as low-priority and then publishing to the store on every frame can cancel out the priority you asked for. Whether that is acceptable is the subject of the transitions lesson, and it is the reason this module ends there rather than here.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Your surface renders on the server and hydration throws a mismatch error the moment the store has any content. What is going on, and what is the fix?
Check your answer
The server has no stream, so there is no accumulated text when the markup is generated. If getServerSnapshot returns something different from what the client’s first getSnapshot returns, the two trees disagree and hydration fails. The classic version of this is a getServerSnapshot that reads the same module-level variable the client mutates, which on the server is whatever the last request left behind.
The fix is to make the server snapshot an explicit empty initial value, and to make sure the store is created per request rather than shared at module scope. A module-level store on the server is a cross-request state leak before it is a hydration bug, and the hydration error is doing you a favour by surfacing it early.
If your surface never server-renders, you still have to supply the argument in the environments that expect it. Read the reference’s section on it rather than passing something plausible.
Hands on
Build the store, then break it on purpose
Done when: The streaming buffer is behind useSyncExternalStore with a published snapshot, and you have reproduced the cached-snapshot failure deliberately, in isolation, and can describe from your own observation what React does when getSnapshot is unstable.
- Write the store in its own module: a mutable buffer, a listener set, a published snapshot, a
flushthat publishes only when the value actually changed, and hoistedsubscribeandgetSnapshotfunctions. Hoisting matters: asubscriberedefined on every render causes React to resubscribe on every render. - Drive it from a fake stream first — a timer appending short strings — not from the model. You want the failure modes to show up in something you can restart in a second.
- Now break it deliberately. Change
getSnapshotto return a freshly built object each call. Observe what React does and read the error text. Write down in your own words what you saw, because this is the failure you will meet again in a codebase you did not write. - Fix it by publishing the object at flush time. Confirm the render count from your Profiler counter matches the number of flushes, not the number of appends.
- Wire it to the real stream, keeping the flush cadence you chose in the previous lesson. Re-run the measurement and confirm the after column is unchanged. It should be: this lesson swaps how the value is read, not how often it is published. A number that moves here means something else changed too.
- Bring the store module and the render count into the chat. I will push back if
subscribeis defined inside the component, if anything derived is computed insidegetSnapshot, or if the store is shared at module scope in a surface that server-renders.
What this does not cover
The store now publishes on a cadence you set. The remaining question is priority rather than cadence: whether the transcript update should yield to a click that arrives mid-render, and what happens when the low-priority work is restarted by the next chunk before it ever commits. The transitions lesson closes this module on that, and it starts from the Transition caveat quoted above.
This lesson does not cover state management libraries. Several implement their React bindings on top of this hook, and if you already use one, reading its store implementation is a better use of an hour than rewriting it. The reason to build the store by hand once is to be able to review theirs.
And it does not touch the amount of DOM the published snapshot turns into. A perfectly correct store still renders an unbounded transcript; the payload module handles that, and the stream’s own transport and lifecycle belong to the Streaming interfaces course.
Read this next — primary source
useSyncExternalStorereact.dev — free; fetched 5 September 2026, no revision date shown on the page. Meta documenting its own library.
This lesson takes the three-argument contract, the Object.is stability requirement on getSnapshot, and the caching failure React names by its own error message. The full reference adds what a lesson has to compress: subscribing to a browser API as a worked example, why the subscribe function’s identity matters and how to hoist it, the getServerSnapshot argument and what happens when server and client snapshots disagree during hydration, and the Transition caveat in full. Read it with your store implementation open beside it.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.