Subscribing to external stores
Read data that lives outside React, like browser APIs or a small store, with useSyncExternalStore, and see the tearing it prevents, live in your browser.
- 01Explain tearingSay why reading outside data while React renders in slices can show two versions of it at once, and how
useSyncExternalStoreprevents it. - 02Subscribe with useSyncExternalStoreWrite
subscribeandgetSnapshotfor browser APIs and your own store, with snapshots that only change when the data does. - 03Build and render a tiny storeWrite a store that many components share, read parts of it with a selector, and give server rendering a
getServerSnapshot.
A cart badge that never hears about changes
Not all data lives in React state. A cart can live in a plain JavaScript module that several parts of the app import. The cart.js tab keeps the items in a variable, and the badge reads them while it renders. Click Add tea and Add cake, and watch the badge and the console.
import { addItem, getItems } from "./cart.js"; function CartBadge() { const items = getItems(); return <p>Cart: <strong>{items.length}</strong> items</p>;} export default function App() { return ( <> <CartBadge /> <button onClick={() => addItem("Tea")}>Add tea</button> <button onClick={() => addItem("Cake")}>Add cake</button> </> );}In cart.js, line 1 keeps the items, and addItem on lines 7 to 10 replaces the array with a longer one and logs the new count. In App.jsx, line 4 reads the items while CartBadge renders. The console says The cart now holds 1, then 2, but the badge still says Cart: 0 items.
React renders a component again when its state or props change, or its parent renders. The cart is none of those: it's data outside React, an external store. Nothing tells React it changed, so CartBadge never reads it again. The fix has two parts. The store has to announce changes, and React has to listen. Here is cart.js as a store that keeps a list of listeners:
let items = [];
const listeners = new Set();
export function getItems() {
return items;
}
export function addItem(name) {
items = [...items, name];
listeners.forEach((listener) => listener());
}
export function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}Line 2 keeps the listeners in a Set. After each change, line 10 calls every one of them. subscribe on lines 13 to 16 adds a listener and returns a function that removes it again. That pattern is the observer pattern. And here are two components that read it with React's useSyncExternalStore. Click the buttons again.
import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function CartBadge() { const items = useSyncExternalStore(subscribe, getItems); return <p>Cart: <strong>{items.length}</strong> items</p>;} function CartList() { const items = useSyncExternalStore(subscribe, getItems); return ( <ul> {items.map((name, index) => ( <li key={index}>{name}</li> ))} </ul> );} export default function App() { return ( <> <CartBadge /> <button onClick={() => addItem("Tea")}>Add tea</button> <button onClick={() => addItem("Cake")}>Add cake</button> <CartList /> </> );}Line 5 hands React two functions from cart.js: subscribe, so React can listen, and getItems, so React can read the current items. Line 10 does the same in CartList. Now each click updates both: Cart: 2 items, with Tea and Cake listed under the buttons. Unlike the two calls to useLocalStorage in Reusing logic with custom hooks, these components can't disagree: they both read the one cart.
subscribe (1). React calls getSnapshot and compares the result with the last one (2). It's a different array, so React renders CartBadge again (3).At a busy café, you don't stand at the counter staring at the kitchen. You take a buzzer and sit down. When it buzzes, you look at the screen. If your order is up, you collect it; if not, you sit back down. When you leave, you return the buzzer.
- In real life: You take a buzzer when you order
- In React: React calls
subscribe(listener) - In real life: The buzzer goes off: something at the counter changed
- In React: The store calls the listener
- In real life: You look at the screen above the counter
- In React: React calls
getSnapshot() - In real life: Your order is there: go and get it. Nothing new: stay seated
- In React: A different value renders again; the same value doesn't
- In real life: You hand the buzzer back when you leave
- In React: React calls the function
subscribereturned
Data outside React doesn't make components render when it changes. useSyncExternalStore(subscribe, getSnapshot) connects it: React subscribes to the data's changes, reads a snapshot of it in every render, and renders again when the snapshot is different. Every component shows the same version, even when React renders in pieces.
Reading outside data while React renders can tear
Reading outside data while rendering, as the first CartBadge did, has a second problem besides missing changes. It appears only when React renders in pieces. Since React 18, React can split a render into slices: it renders a few components, gives the browser a moment to handle clicks and timers, then carries on. It does that for updates marked as not urgent with startTransition, so a slow list doesn't freeze the page. (The Transitions lesson covers them.)
Here is a price list whose six rows are slow on purpose. The prices.js tab keeps the price, starting at $3, and raisePriceSoon raises it to $4 after 100 ms. Click Show prices.
import { startTransition, useState } from "react";import { getPrice, raisePriceSoon } from "./prices.js"; const products = ["Tea", "Cake", "Lemonade", "Coffee", "Juice", "Water"]; function SlowRow({ name }) { const start = performance.now(); while (performance.now() - start < 30) { // Pretend this row takes 30 ms to render. } const price = getPrice(); return <li>{name}: ${price}</li>;} export default function App() { const [shown, setShown] = useState(false); function showPrices() { raisePriceSoon(); startTransition(() => setShown(true)); } return ( <> <button onClick={showPrices}>Show prices</button> <ul>{shown && products.map((name) => <SlowRow key={name} name={name} />)}</ul> </> );}Lines 7 to 10 keep each SlowRow busy for 30 ms. (In development, StrictMode renders each component twice, so a row takes 60 ms.) Line 11 reads the price straight from prices.js. When you click, line 19 starts the 100 ms timer, and line 20 shows the rows in a transition. The list shows two prices at once: the first rows at $3 and the rest at $4, though they're one list from one render. (Which row the split falls after depends on how fast your computer is.)
This is called tearing: one screen showing two versions of the same data. React rendered a row, then paused to let the browser run, then rendered the next. The timer fired in that pause and changed the price, and React carried on with rows that read the new one. React can't know: to React, getPrice() is just a function that returned a different number.
Press Edit & run and change line 20 to setShown(true);. That update is urgent, so React renders all six rows in one go, and the timer has to wait. Every row says $3. That isn't torn, but it's wrong too: the price is $4 now, the console says so, and nothing tells React. Now the same list with each row reading the price through useSyncExternalStore:
import { startTransition, useState, useSyncExternalStore } from "react";import { getPrice, raisePriceSoon, subscribe } from "./prices.js"; const products = ["Tea", "Cake", "Lemonade", "Coffee", "Juice", "Water"]; function SlowRow({ name }) { const start = performance.now(); while (performance.now() - start < 30) { // Pretend this row takes 30 ms to render. } const price = useSyncExternalStore(subscribe, getPrice); return <li>{name}: ${price}</li>;} export default function App() { const [shown, setShown] = useState(false); function showPrices() { raisePriceSoon(); startTransition(() => setShown(true)); } return ( <> <button onClick={showPrices}>Show prices</button> <ul>{shown && products.map((name) => <SlowRow key={name} name={name} />)}</ul> </> );}Apart from the imports on lines 1 and 2, only line 11 changed. Click Show prices: every row says $4. React remembers the snapshot each row read. Before it puts a render made in slices on the screen, it calls getSnapshot once more. When the value has changed, as it did here, React throws that render away before showing it, and renders again in one go, with no pause for the store to change in. (Concurrent rendering, Suspense and transitions inside, near the end of the course, reads React's code for this check.) With the urgent version, every row says $4 as well: after the render, the store calls its listeners, and React renders the rows again.
getPrice(), the rows rendered after it read $4. With useSyncExternalStore, React noticed and rendered every row again in one go.Try all four combinations in the playground. Pick how the rows read the price and how the list is shown, then press Show prices. New prices are marked.
getPrice()Press Show prices. The price changes from $3 to $4, 100 ms later, while the rows are still rendering.
Only one combination tears: reading the price directly, in a transition. That's why React gives you a hook for outside data, rather than letting you read it while rendering. It's safe in every kind of render, today's and the ones React adds later.
useSyncExternalStore: subscribe and getSnapshot
useSyncExternalStore was added in React 18. You call it at the top level of a component or a custom hook, like any hook, with two functions, and it returns the current value:
subscribe(listener)starts listening to the store and callslistenerwhenever the data changes. It returns a function that stops listening. React calls it after the component appears on the screen, and calls what it returned when the component goes away.getSnapshot()returns the data as it is now: the snapshot. React calls it while rendering, and again each time the listener runs. It compares the new snapshot with the last one usingObject.is; a different value renders the component again, the same value doesn't.
Without this hook, you'd subscribe in an Effect and copy the value into state, the way the first useOnlineStatus in Reusing logic with custom hooks did. That works, but the first render shows the state's starting value, true, instead of the real one, and it takes more code. useSyncExternalStore reads the real value in the first render and subscribes for you.
This version of cart.js logs each subscribe and unsubscribe. Watch the console as the page starts, then click Hide list, Add tea, and Show list.
import { useState, useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function CartBadge() { const items = useSyncExternalStore(subscribe, getItems); return <p>Cart: <strong>{items.length}</strong> items</p>;} function CartList() { const items = useSyncExternalStore(subscribe, getItems); return <p>In the cart: {items.join(", ") || "nothing yet"}</p>;} export default function App() { const [listShown, setListShown] = useState(true); return ( <> <CartBadge /> <button onClick={() => addItem("Tea")}>Add tea</button> <button onClick={() => setListShown(!listShown)}>{listShown ? "Hide list" : "Show list"}</button> {listShown && <CartList />} </> );}The cart.js tab is the store from the overview, with lines 15 and 18 logging how many listeners there are. When the page starts, both components subscribe: subscribe: 1 listening, then 2. In development, StrictMode then removes them and adds them again, so you see two unsubscribes and two more subscribes. Hide list removes CartList, so React calls its unsubscribe function: unsubscribe: 1 listening. Add tea changes the badge; the hidden list hears nothing. Show list renders CartList again, and line 10 reads the cart as it is now: In the cart: Tea. Then it subscribes again, subscribe: 2 listening, and StrictMode checks that new subscription the same way: one unsubscribe, one subscribe.
React compares the subscribe function too. If you pass a different one, it unsubscribes with the old one and subscribes with the new one. A function written inside a component is a new function on every render. Click Like this shop a few times:
import { useState, useSyncExternalStore } from "react";import { getItems, subscribe } from "./cart.js"; function CartBadge() { const [liked, setLiked] = useState(false); const items = useSyncExternalStore((listener) => subscribe(listener), getItems); return ( <p> Cart: <strong>{items.length}</strong> items{" "} <button onClick={() => setLiked(!liked)}>{liked ? "Liked" : "Like this shop"}</button> </p> );} export default function App() { return <CartBadge />;}Line 6 wraps subscribe in a new arrow function on every render. Each click on line 10 renders CartBadge, so the console prints unsubscribe: 0 listening and subscribe: 1 listening each time. It still works, but it does needless work, and for some stores, like a server connection, resubscribing is expensive. Declare subscribe outside the component, as cart.js does, and pass it as it is.
A snapshot stays the same until the data changes
Because React compares snapshots with Object.is, getSnapshot has one rule: while the store hasn't changed, it must return the same value every time. That's easy to break by accident. This component only wants the teas in the cart, so it filters inside getSnapshot:
import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function TeaCount() { const teas = useSyncExternalStore(subscribe, () => getItems().filter((name) => name.endsWith("tea")), ); return <p>Teas in the cart: {teas.length}</p>;} export default function App() { return ( <> <TeaCount /> <button onClick={() => addItem("Green tea")}>Add green tea</button> </> );}Lines 5 to 7 pass a getSnapshot that calls filter, and filter returns a new array every time. In development, React checks the snapshot by calling it twice in a row, and two new arrays are never Object.is equal, even when both are empty. So the console shows React's warning, The result of getSnapshot should be cached to avoid an infinite loop. Then every render finds a "new" snapshot that asks for another render, until React stops with Maximum update depth exceeded. That's the error in place of the result.
getSnapshot more than once and compares the results with Object.is. Two new arrays are never equal, even with the same items inside. The store's own array is the same array until the store replaces it.The fix is to give React the store's own array, which only changes when the cart does, and to filter it while rendering:
import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function TeaCount() { const items = useSyncExternalStore(subscribe, getItems); const teas = items.filter((name) => name.endsWith("tea")); return <p>Teas in the cart: {teas.length}</p>;} export default function App() { return ( <> <TeaCount /> <button onClick={() => addItem("Green tea")}>Add green tea</button> <button onClick={() => addItem("Lemon cake")}>Add lemon cake</button> </> );}Line 5 reads getItems, the same array until addItem replaces it. Line 6 filters it during the render, like any value worked out from state. Click Add green tea and Add lemon cake: the count says Teas in the cart: 1, and only the first click changed it.
The opposite mistake is just as quiet. A store that changes its array in place, with items.push(name), keeps returning the same array. To Object.is nothing changed, so React never renders again. That's why the cart store builds a new array on line 9 with [...items, name], as you did for state in Updating arrays in state. Exercise 1 lets you watch it happen.
How to read the table: each row is something a getSnapshot might return, whether a second call gives the same value, and what React does.
| getSnapshot returns | The same value on a second call? | What React does |
|---|---|---|
getItems, the store's own array | Yes, until addItem replaces it with a new array. | Renders once for each change. |
getItems().filter(…) or [...getItems()] | No. It builds a new array on every call. | Warns that the result should be cached, then stops with Maximum update depth exceeded. |
{ width, height }, built from the window | No. It builds a new object on every call. | The same warning and the same error. |
navigator.onLine or window.innerWidth | Yes. True, false, and numbers compare by value. | Renders when the value changes. |
An array changed in place with push | Yes: it's the same array, even after the change. | Never renders again. To Object.is, nothing changed. |
Subscribe to the browser
The browser is full of external stores: whether you're online, the window's size, whether the screen is wide or the reader prefers dark colors. Each has an event that says it changed, and a property that holds the value now. That's exactly a subscribe and a getSnapshot. Here are three custom hooks, in browser.js:
import { useSyncExternalStore } from "react";
function subscribeToNetwork(callback) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribeToNetwork, () => navigator.onLine);
}
function subscribeToResize(callback) {
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
}
export function useWindowWidth() {
return useSyncExternalStore(subscribeToResize, () => window.innerWidth);
}
const wideScreen = window.matchMedia("(min-width: 600px)");
function subscribeToWideScreen(callback) {
wideScreen.addEventListener("change", callback);
return () => wideScreen.removeEventListener("change", callback);
}
export function useIsWideScreen() {
return useSyncExternalStore(subscribeToWideScreen, () => wideScreen.matches);
}useOnlineStatus on lines 12 to 14 subscribes to the online and offline events (lines 3 to 10) and reads navigator.onLine, the pair you met in You might not need an effect. It has the same name and returns the same value as the one in Reusing logic with custom hooks, so components that call it don't change at all: only the hook's insides do. useWindowWidth on lines 21 to 23 listens for resize and reads window.innerWidth, a number.
useIsWideScreen uses a media query, the same test CSS uses in @media rules. Line 25 asks the browser once for a MediaQueryList for (min-width: 600px). Its matches is true or false, and it fires change when that flips (lines 27 to 30). The live example shows all three:
import { useIsWideScreen, useOnlineStatus, useWindowWidth } from "./browser.js"; export default function App() { const isOnline = useOnlineStatus(); const width = useWindowWidth(); const isWide = useIsWideScreen(); return ( <ul> <li>Network: {isOnline ? "online" : "offline"}</li> <li>This page is {width}px wide</li> <li>Layout: {isWide ? "two columns" : "one column"}</li> </ul> );}Lines 4 to 6 call the three hooks. Make your browser window narrower and wider: line 11 follows the width of the result, and line 12 switches between two columns and one column at 600 pixels. To see line 10 change, turn off your Wi-Fi, or open Chrome's DevTools, go to the Network tab, and choose Offline. The pretend buttons from the last lesson wouldn't work here: they fired the events without changing navigator.onLine, and getSnapshot reads the real value.
Notice what each getSnapshot returns: a true or false, or a number. A hook that returned { width, height } would build a new object on every call, the mistake from the last section. Use one hook per number, or keep the object in a store that only replaces it on resize.
How to read the table: each row is a browser value, the event subscribe listens for, and what getSnapshot returns.
| What you read | subscribe listens for | getSnapshot returns |
|---|---|---|
| Whether the browser is online | online and offline events on window | navigator.onLine |
| The window's width | resize events on window | window.innerWidth |
| A media query, like a wide screen | change events on the MediaQueryList | wideScreen.matches |
| Whether the tab is visible | visibilitychange events on document | document.visibilityState |
Build a tiny store
The cart store works, but every store would repeat the same listener code. A function can make stores for you, and a custom hook can read any of them. This is all a basic store needs:
import { useSyncExternalStore } from "react";
export function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState() {
return state;
},
setState(next) {
state = next;
listeners.forEach((listener) => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
export function useStore(store, select = (state) => state) {
return useSyncExternalStore(store.subscribe, () => select(store.getState()));
}createStore on lines 3 to 19 keeps one state and a set of listeners inside a closure, and returns three functions. getState returns the state. setState replaces it and calls every listener (lines 10 to 13). subscribe works like the cart's. Because the store is made once, outside any component, store.subscribe is the same function on every render.
useStore on lines 21 to 23 is a custom hook that reads a store. Its optional select picks the part a component needs, like (items) => items.length. It must return a part of the state or a plain value: a selector that builds a new object or array is the snapshot mistake again. Here it fixes the theme page from the last lesson, with a theme.js that saves every change:
import { createStore } from "./store.js";
export const themeStore = createStore(localStorage.getItem("theme") ?? "light");
themeStore.subscribe(() => {
localStorage.setItem("theme", themeStore.getState());
});Line 3 makes the store, starting from what localStorage holds. Lines 5 to 7 subscribe outside React: every change is saved. Now the header and the picker each call useStore(themeStore). Click Switch theme, then press Run again.
import { useStore } from "./store.js";import { themeStore } from "./theme.js"; function ShopHeader() { const theme = useStore(themeStore); return <h3>Shop header, drawn in the {theme} theme</h3>;} function ThemePicker() { const theme = useStore(themeStore); return ( <p> Picker: <strong>{theme}</strong>{" "} <button onClick={() => themeStore.setState(theme === "light" ? "dark" : "light")}>Switch theme</button> </p> );} export default function App() { return ( <> <ShopHeader /> <ThemePicker /> </> );}Both lines switch together: Shop header, drawn in the dark theme and Picker: dark. Line 14 changes the store, not a component's state, and both components hear about it. After Run again, both start dark, because line 3 of theme.js read the saved theme. Compare that with the custom hook, where each call kept its own state: a store is one state that any component can read.
Libraries for shared state are bigger versions of this store. Zustand's hook calls useSyncExternalStore directly, and React Redux's useSelector uses a version of it with a selector built in. Knowing these 23 lines, you know what they do when a component reads them.
Server rendering and getServerSnapshot
Frameworks like Next.js can render your components to HTML on a server, so the page shows its content before any JavaScript loads. Then, in the browser, React hydrates that HTML: it renders the same components again and takes over the elements that are already there, instead of making new ones. A server has no window, no navigator, and no localStorage, so getSnapshot can't run there:
import { renderToString } from "react-dom/server";
import App from "./App.jsx";
// On the server: no window, no navigator, no localStorage.
const html = renderToString(<App />);Line 5 renders App to a string of HTML. If App calls useSyncExternalStore with two arguments, it throws Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering. The third argument, getServerSnapshot, gives React the value to use on the server. React also uses it in the browser while hydrating, so the first render there matches the HTML the server sent. Then it reads getSnapshot and renders again if the value differs.
This example is a page whose HTML came from a server: the index.html tab already holds Measuring the page... inside #root. Read the console under the result.
import { useSyncExternalStore } from "react";import { hydrateRoot } from "react-dom/client"; function subscribeToResize(callback) { window.addEventListener("resize", callback); return () => window.removeEventListener("resize", callback);} function useWindowWidth() { return useSyncExternalStore( subscribeToResize, () => window.innerWidth, () => null, );} function App() { const width = useWindowWidth(); console.log(`Render with width ${width}`); return <p>{width === null ? "Measuring the page..." : `This page is ${width}px wide`}</p>;} hydrateRoot(document.getElementById("root"), <App />);Line 13 is getServerSnapshot: null, meaning "not measured yet", which is what the server rendered with. Line 23 calls hydrateRoot instead of createRoot, so React takes over the existing HTML. The console shows Render with width null: the first render matches the HTML exactly. Then Render with width and a number, and the page shows the real width. The value from the server must be the same on both sides; a server snapshot that guessed a width would draw a different page from the HTML.
getServerSnapshot. While hydrating, the browser renders with it again, so its first render matches the HTML. Then React reads getSnapshot, gets the real width (712 here, yours will differ), and renders again.Press Edit & run and delete line 13. Now hydration has no server snapshot, and the result shows the error React reports: There was an error while hydrating but React was able to recover by instead client rendering the entire root. React did recover: the console shows one Render with width line with a number, from a render in the browser. But it dropped the server's HTML and built the whole page again, so the work server rendering was for is lost. Code at the top of a module runs on the server too: line 25 of browser.js calls window.matchMedia when the file loads, so in a server-rendered app, move it inside subscribe and getSnapshot.
A shop header that reads three outside sources
A real header reads several stores at once. This one shows the cart count from a store made with createStore, a delivery note only on wide screens, and a warning when you're offline. Click the buttons, and make the window narrower.
import { useStore } from "./store.js";import { addToCart, cartStore } from "./cart.js";import { useIsWideScreen, useOnlineStatus } from "./browser.js"; function ShopHeader() { const itemCount = useStore(cartStore, (items) => items.length); const isOnline = useOnlineStatus(); const isWide = useIsWideScreen(); return ( <header> <strong>Tea Shop</strong> {isWide && <span> · Free delivery over $20</span>} <p>Cart: {itemCount}{!isOnline && " (offline: saved on this device)"}</p> </header> );} export default function App() { return ( <> <ShopHeader /> <button onClick={() => addToCart("Tea")}>Add tea</button> <button onClick={() => addToCart("Cake")}>Add cake</button> </> );}Line 6 reads the cart with a selector that returns a number, so the header renders only when the count changes. Lines 7 and 8 use the browser hooks from browser.js. The buttons on lines 23 and 24 call addToCart in the cart.js tab, which replaces the array with setState. Nothing in App passes data down: each piece comes from its store.
() => navigator.onLine() => window.innerWidth() => ({ width: window.innerWidth, height: window.innerHeight })() => cartStore.getState()() => cartStore.getState().length() => [...cartStore.getState()]() => JSON.parse(localStorage.getItem("cart") ?? "[]")() => localStorage.getItem("theme")
Sort each getSnapshot: does it return the same value until the data changes, or a new value every time React calls it?
Common mix-ups
- "useSyncExternalStore is a better useState." No. It's for data that lives outside React. State that belongs to a component stays in
useStateoruseReducer. - "React compares what's inside two snapshots." No. It uses
Object.is: two new arrays with the same items are different, and one array changed in place is the same. - "Reading a module variable while rendering is fine if I re-render often." No. It can tear during a transition, and it never re-renders by itself when the value changes.
- "Tearing happens all the time in React apps." No. It needs a render made in slices and a value that changes between them.
useSyncExternalStorerules it out. - "React calls subscribe on every render." Only when you pass a new function. Declare it outside the component and React subscribes once per component.
- "A shared store needs a library." No.
createStoreis under 20 lines. Libraries add features like selectors, middleware, and dev tools.
Practice exercises
Each exercise shows its result under the code, with the console under it. Press Edit & run to change the code, and open the other tabs to change the stores. Make a guess first, then use the hints.
Predict first: you click Add tea twice. What does the badge show? Then click in the result.
import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function CartBadge() { const items = useSyncExternalStore(subscribe, getItems); return <p>Cart: <strong>{items.length}</strong> items</p>;} export default function App() { return ( <> <CartBadge /> <button onClick={() => addItem("Tea")}>Add tea</button> </> );}import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function CartBadge() { const items = useSyncExternalStore(subscribe, getItems); return <p>Cart: <strong>{items.length}</strong> items</p>;} export default function App() { return ( <> <CartBadge /> <button onClick={() => addItem("Tea")}>Add tea</button> </> );}The console says the cart holds 2, but the badge stays at Cart: 0 items. The listener runs and React calls getItems, which returns the same array it returned before. To Object.is, the snapshot didn't change, so React skips the render.
Change the store so the badge updates. After your fix, click Add tea twice. What does the badge show?
import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function CartBadge() { const items = useSyncExternalStore(subscribe, getItems); return <p>Cart: <strong>{items.length}</strong> items</p>;} export default function App() { return ( <> <CartBadge /> <button onClick={() => addItem("Tea")}>Add tea</button> </> );}import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; function CartBadge() { const items = useSyncExternalStore(subscribe, getItems); return <p>Cart: <strong>{items.length}</strong> items</p>;} export default function App() { return ( <> <CartBadge /> <button onClick={() => addItem("Tea")}>Add tea</button> </> );}With items = [...items, name], each change makes a new array, so getItems returns a different snapshot and React renders the badge: Cart: 2 items after two clicks.
This component reads whether its tab is visible, but never updates. What does it say when it first loads? Then make it follow the tab, so switching to another tab and back logs new renders.
export default function App() { const visibility = document.visibilityState; console.log(`Render: ${visibility}`); return <p>This tab is {visibility}</p>;}import { useSyncExternalStore } from "react"; function subscribeToVisibility(callback) { document.addEventListener("visibilitychange", callback); return () => document.removeEventListener("visibilitychange", callback);} function useVisibility() { return useSyncExternalStore(subscribeToVisibility, () => document.visibilityState);} export default function App() { const visibility = useVisibility(); console.log(`Render: ${visibility}`); return <p>This tab is {visibility}</p>;}The starter reads document.visibilityState once per render and never hears about changes. Subscribed to visibilitychange, the component renders again when you switch tabs: come back and the console shows Render: hidden, then Render: visible.
The summary stops with an error. Fix it so it shows the count and the total. Then click Add tea and Add cake. What does it say?
import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; const prices = { Tea: 3, Cake: 4 }; function CartSummary() { const summary = useSyncExternalStore(subscribe, () => ({ count: getItems().length, total: getItems().reduce((sum, name) => sum + prices[name], 0), })); return <p>{summary.count} items, total ${summary.total}</p>;} export default function App() { return ( <> <CartSummary /> <button onClick={() => addItem("Tea")}>Add tea</button> <button onClick={() => addItem("Cake")}>Add cake</button> </> );}import { useSyncExternalStore } from "react";import { addItem, getItems, subscribe } from "./cart.js"; const prices = { Tea: 3, Cake: 4 }; function CartSummary() { const items = useSyncExternalStore(subscribe, getItems); const total = items.reduce((sum, name) => sum + prices[name], 0); return <p>{items.length} items, total ${total}</p>;} export default function App() { return ( <> <CartSummary /> <button onClick={() => addItem("Tea")}>Add tea</button> <button onClick={() => addItem("Cake")}>Add cake</button> </> );}The object literal was a new snapshot on every call, so React stopped with Maximum update depth exceeded. Reading the store's array and working out items.length and the total in the render fixes it: 2 items, total $7.
Keep the transition, and make every row show the same price. After your fix, click Show prices. How many different prices does the list show?
import { startTransition, useState } from "react";import { getPrice, raisePriceSoon } from "./prices.js"; const products = ["Tea", "Cake", "Lemonade", "Coffee", "Juice", "Water"]; function SlowRow({ name }) { const start = performance.now(); while (performance.now() - start < 30) { // Pretend this row takes 30 ms to render. } const price = getPrice(); return <li>{name}: ${price}</li>;} export default function App() { const [shown, setShown] = useState(false); function showPrices() { raisePriceSoon(); startTransition(() => setShown(true)); } return ( <> <button onClick={showPrices}>Show prices</button> <ul>{shown && products.map((name) => <SlowRow key={name} name={name} />)}</ul> </> );}import { startTransition, useState, useSyncExternalStore } from "react";import { getPrice, raisePriceSoon, subscribe } from "./prices.js"; const products = ["Tea", "Cake", "Lemonade", "Coffee", "Juice", "Water"]; function SlowRow({ name }) { const start = performance.now(); while (performance.now() - start < 30) { // Pretend this row takes 30 ms to render. } const price = useSyncExternalStore(subscribe, getPrice); return <li>{name}: ${price}</li>;} export default function App() { const [shown, setShown] = useState(false); function showPrices() { raisePriceSoon(); startTransition(() => setShown(true)); } return ( <> <button onClick={showPrices}>Show prices</button> <ul>{shown && products.map((name) => <SlowRow key={name} name={name} />)}</ul> </> );}With useSyncExternalStore, React sees that the price changed during the sliced render, throws that render away, and renders the rows again in one go. Every row says $4: one price.
Make the cart survive Run again, by changing only cart.js. After Add tea, Add cake, and Run again, what does the cart say?
import { useStore } from "./store.js";import { addToCart, cartStore } from "./cart.js"; export default function App() { const itemCount = useStore(cartStore, (items) => items.length); return ( <> <p>Cart: {itemCount} items</p> <button onClick={() => addToCart("Tea")}>Add tea</button> <button onClick={() => addToCart("Cake")}>Add cake</button> </> );}import { useStore } from "./store.js";import { addToCart, cartStore } from "./cart.js"; export default function App() { const itemCount = useStore(cartStore, (items) => items.length); return ( <> <p>Cart: {itemCount} items</p> <button onClick={() => addToCart("Tea")}>Add tea</button> <button onClick={() => addToCart("Cake")}>Add cake</button> </> );}The store now starts from the saved cart, and a subscription outside React saves each change as JSON. After Add tea, Add cake, and Run again, the page starts with Cart: 2 items. App didn't change.
Check your understanding
Question 1 of 7What is tearing?
Choose an answer to see the explanation.
Question 2 of 7You click Add tea three times. What does the badge show?
Read the code, then predictlet count = 0; function Badge() { return <p>Cart: {count}</p>; } export default function App() { return ( <> <Badge /> <button onClick={() => { count = count + 1; }}>Add tea</button> </> ); }Choose an answer to see the explanation.
Question 3 of 7A store calls the listener React gave to
subscribe. What does React do next?Choose an answer to see the explanation.
Question 4 of 7What happens when a component renders this line?
Read the code, then predictJSXconst items = useSyncExternalStore(subscribe, () => [...getItems()]);Choose an answer to see the explanation.
Question 5 of 7Why should
subscribebe declared outside the component?Choose an answer to see the explanation.
Question 6 of 7A server renders a component that calls
useSyncExternalStore(subscribe, getSnapshot), with no third argument. What happens?Choose an answer to see the explanation.
Question 7 of 7When can a component that reads a module variable while rendering show two different prices at once?
Choose an answer to see the explanation.
Key takeaways
- Data outside React, like a module, a browser API, or a shared store, doesn't make components render when it changes.
- Read it with
useSyncExternalStore(subscribe, getSnapshot): React listens, reads a snapshot in each render, and renders again when it differs. - Reading it directly can tear during a transition: two parts of one screen show different versions. The hook rules that out.
getSnapshotreturns the same value until the data changes: never a new object or array per call, and never an array changed in place.- Declare
subscribeoutside the component, or React resubscribes after every render. - A tiny store is a state, a set of listeners, and three functions. Give server rendering a
getServerSnapshot.
Subscribe to changes, snapshot the value.useSyncExternalStore lets components read outside data that stays in sync, and on one version.
Coming next: Fetching data. It starts the next stage: loading data from a server in an Effect, without race conditions, and why apps reach for a data library or framework.