What belongs in global state, and what doesn’t. Build a small store for the whole app with no library and no provider, save it, log it, and learn its costs.
In Part 23 we used context to hand a value to many components at once. In Part 24 a provider component owned the state and shared it. In Part 25 we built a small store with selectors, so a component renders only when its own part changes.
This part ends Stage 4 with two questions. First, which state really needs to be shared by the whole app? Less than you might think. Second, how do you share it without a library?
We’ll build a small store that any component can use, with no provider at all. We’ll add selectors, a logger and saving to localStorage. Then we’ll look at what this kind of store costs, and when a library is the better choice.
Try this first
Read this code, but don’t press Run yet.
import { useSyncExternalStore } from 'react'
type ShopState = { user: string; cart: string[] }
function createStore<T>(first: T) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: T) => T) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
const store = createStore<ShopState>({ user: 'Ana', cart: [] })
function useStore<T>(selector: (state: ShopState) => T): T {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()))
}
function addToCart(item: string) {
store.setState(old => ({ ...old, cart: [...old.cart, item] }))
}
function Greeting() {
const user = useStore(state => state.user)
console.log('Greeting renders')
return <p>Hello, {user}!</p>
}
function CartBadge() {
const count = useStore(state => state.cart.length)
console.log('CartBadge renders')
return <p>Cart: {count}</p>
}
function Header() {
console.log('Header renders')
return (
<header>
<Greeting />
<CartBadge />
</header>
)
}
function ProductList() {
console.log('ProductList renders')
return <button onClick={() => addToCart('apple')}>Add an apple</button>
}
export default function App() {
return (
<>
<Header />
<main>
<ProductList />
</main>
</>
)
}
Header renders
Header renders
Greeting renders
Greeting renders
CartBadge renders
CartBadge renders
ProductList renders
ProductList renders
CartBadge renders
CartBadge renders
Look for the provider. There isn’t one. store is a plain variable at the top of the file. CartBadge sits inside Header. The button sits inside ProductList, in a different branch of the tree. Nothing passes props between them.
Make a guess. When you click “Add an apple”, will the badge change? And which components will render again?
Now press Run, click the button once, and look at the Console.
The first eight lines are the first render. Each line appears twice because of Strict Mode. As Part 2 showed, React calls each component twice in development to find bugs.
After the click, only CartBadge renders, twice because of Strict Mode. The badge now says 1. Header didn’t render, though the badge is inside it. ProductList didn’t render, though the click happened there. App has no console.log. But it has no state and reads no store, so nothing makes it render.
What “global state” means
Global state is state that many parts of an app need, often in places far apart in the tree. Here “global” means “for the whole app”. The signed-in user, the color theme, the language and a shopping cart are common examples.
Most state is not like that. Before you make something global, ask what kind of state it is.
- Local state. Only one component uses it, like a menu that is open, or a counter on one card. Keep it in that component with
useState. Part 9 said to keep state as low as you can. - Form state. This is the text a person is typing, before they send it. It belongs to the form. Part 28 and Part 29 are about forms.
- URL state. The page address can hold state too. In
/shop?q=apple, the part after?holds a search word. A person who opens that link sees the same search. Part 37 is about routing, which reads the address. - Server data. A list of products from a server is not really your app’s state. It is a copy of the server’s data. Part 13 called keeping such copies caching. It named TanStack Query and SWR, two libraries that do it for you.
The docs of TanStack Query explain the last point well. They call TanStack Query “a server-state library”. They call Redux, Zustand and others “client-state libraries”. Their example is a global store with six fields. Four of them come from the server: projects, teams, tasks and users. The other two are a theme mode and the state of a side menu. Move the four to TanStack Query, and only the theme mode and the side menu are left. In their words, what is left “is usually very tiny”.
So the real global state in many apps is small. It is often just who is signed in, the theme and the language. It may also hold a cart kept only in the browser.
A decision guide
When several components need the same state, try these steps in order. Move down only when the step above stops working.
| Step | Use it when | Part |
|---|---|---|
| 1. Keep it local | only one component uses it | Part 4 |
| 2. Lift it up | a few nearby components need it | Part 9 |
3. Pass JSX as children |
props pass through parts that don’t use them | Part 23, Part 19 |
| 4. A provider with context and a reducer | many components in one area need it | Part 24 |
5. A store with useSyncExternalStore |
many far-apart components each read a small part, or code outside React must change it | Part 25 and this part |
React’s docs give the same order for the first steps. Before you use context, they say: “Start by passing props.” Next, they say to pass JSX as children. Then: “If neither of these approaches works well for you, consider context.”
The docs are careful about stores too. The page for useSyncExternalStore says: “When possible, we recommend using built-in React state with useState and useReducer instead.”
The same page says the hook is mostly for working with code that isn’t React. A state library is one kind of such code. So a store is the last step, not the first.
Building a store for the whole app
The store in “Try this first” has four pieces. Let’s take them one at a time.
The store lives in a module
In Part 10 we saw that each file of your code is a module. Code at the top level of a module runs once, the first time the module is loaded. MDN says it plainly: “Modules are only executed once.”
So const store = createStore(...) makes one store. In a real project, you’d put it in a file of its own, like store.ts, and export it. Every file that imports store gets that same object. We checked with Node: two files imported the same store module. The module ran 1 time, and both files got the same object.
There is one exception while you develop. When you edit store.ts, Vite runs the edited file again, and the files that import it get the new store. Vite’s docs on hot updates show this: the code gets the updated module as a new module. So after an edit, the store starts fresh.
That is why no provider is needed. A provider hands a value down the tree. A module hands it to any file that asks with import. React’s own example on the useSyncExternalStore page does this. Its component imports todosStore from a file called todoStore.js.
In the playground, the whole example is one file. It works the same way.
createStore: state, listeners and three functions
createStore is the same as the store in Part 25, with one change. It takes any kind of state. The <T> after its name is a TypeScript type parameter: a stand-in for the type of the state. When we write createStore<ShopState>(...), T becomes ShopState.
A listener is a function that wants to hear about changes, as Part 25 explained. The store keeps its listeners in a Set.
getState()gives back the current state.setState(update)makes the new state from the old one, then calls every listener.subscribe(listener)adds a listener and gives back a function that removes it.
Actions are plain functions
addToCart is an action function: a plain function that changes the store. It isn’t a hook, and it isn’t inside a component. A click handler can call it. So can a timer, or a test.
This is a little different from Part 15. There, an action was an object that you passed to dispatch, and a reducer did the work. Here, the action function does the work itself, with setState. Both ways keep the rules for changing state in one place, outside the components.
Each action makes new objects and never changes the old state. That’s the rule from Part 5, and the store depends on it. You’ll see why under Common mistakes.
useStore(selector) connects a component
useStore takes a selector and gives back what it selects. Inside, it calls useSyncExternalStore with two functions.
store.subscribetells React how to hear about changes.() => selector(store.getState())tells React how to read the part this component needs.
When the store changes, React renders the component again only when that part is different, by Object.is. Part 25 showed this in detail. A selector stops only the renders that come from the store. The component still renders when its parent renders, as Part 25 showed.
store.subscribe is the same function on every render, because the store is made only once. That matters. React’s docs say that if a different subscribe function is passed during a re-render, “React will re-subscribe to the store”. A store at module level never has that problem.
What happens on a click
Here is one click, step by step.
A store outside React: only the component whose part changed renders. Press play, or step through it.
Here are the same steps in words.
- The store lives outside React, in the module.
GreetingandCartBadgecalluseStore, so each one subscribes. We counted: after the first render, the store’slisteners.sizewas 2. - You click in
ProductList. The click callsaddToCart('apple'), andaddToCartcallssetState. - The store now holds a new cart. It calls each listener.
- React runs each selector.
Greeting‘s selector still gives'Ana', the same as last time.CartBadge‘s selector gives 1. Last time it gave 0. - React renders only
CartBadgeagain.App,Header,GreetingandProductListdon’t render.
Counting the renders
We clicked “Add an apple” 3 times and counted renders after the first one. We built the same app two more ways, with a provider.
- Part 24’s way.
useReducerholds the cart. The cart anddispatchare in two contexts, and the user is in a third. - The way Part 24 warned against. One provider with
useState, and one context value that holds the user, the cart andaddToCart.
| App | Header |
Greeting |
CartBadge |
ProductList |
The provider |
|---|---|---|---|---|---|
| Store at module level | 0 | 0 | 3 | 0 | (none) |
| Provider, Part 24’s way | 0 | 0 | 3 | 0 | 3 |
| Provider, one context (the version Part 24 warned against) | 0 | 3 | 3 | 3 | 3 |
All counts are with Strict Mode off. With it on, every number that isn’t 0 doubles.
Header rendered 0 times in all three. In the provider apps it comes in as children, as Part 19 showed. Done Part 24’s way, the provider gets the same result as the store, plus the provider’s own renders. It needs three contexts to get there. With one context, every reader rendered, even ProductList, which only needs addToCart. The store gets there with one store and selectors, and no provider.
An everyday example
Think of one shopping list on the kitchen wall of a family’s home. Anyone in any room can add to it. When someone adds something, they ring a small bell.
Each person cares about one thing. One person cares whether milk is on the list. Another cares how long the list is. When the bell rings, each person walks over and checks only their own thing. If their thing didn’t change, they go back to what they were doing.
The exact version
This picture breaks in two places, and both matter later.
First, everyone checks on every bell. In the store, every selector runs on every change, even when its part didn’t change. So selectors must be small and fast.
Second, there is one list for the whole home. If two families shared the home, they would share the list too. A module-level store is the same. Everything that imports it shares one store. The cost of a store at module level comes back to this.
Work out values with selectors
Here is a fuller version. The cart holds items with prices. There are two action functions, and two named selectors.
import { useState, useSyncExternalStore } from 'react'
type Item = { name: string; price: number }
type ShopState = { user: string; cart: Item[] }
function createStore<T>(first: T) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: T) => T) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
// The store: one for the whole app.
const store = createStore<ShopState>({ user: 'Ana', cart: [] })
function useStore<T>(selector: (state: ShopState) => T): T {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()))
}
// Actions: plain functions that change the store.
function addToCart(item: Item) {
store.setState(old => ({ ...old, cart: [...old.cart, item] }))
}
function emptyCart() {
store.setState(old => ({ ...old, cart: [] }))
}
// Selectors: work out values from the state.
const selectCount = (state: ShopState) => state.cart.length
const selectTotal = (state: ShopState) =>
state.cart.reduce((sum, item) => sum + item.price, 0)
function CartPanel() {
const count = useStore(selectCount)
const total = useStore(selectTotal)
return (
<div>
<p>Items: {count}</p>
<p>Total price: {total}</p>
<button onClick={emptyCart}>Empty the cart</button>
</div>
)
}
function Product({ item }: { item: Item }) {
return (
<button onClick={() => addToCart(item)}>
Add {item.name} ({item.price})
</button>
)
}
export default function App() {
const [showCart, setShowCart] = useState(true)
return (
<div>
<Product item={{ name: 'apple', price: 3 }} />
<Product item={{ name: 'milk', price: 5 }} />
<button onClick={() => setShowCart(!showCart)}>
{showCart ? 'Hide' : 'Show'} the cart
</button>
{showCart && <CartPanel />}
</div>
)
}
Run it. Add an apple and some milk. The panel says “Items: 2” and “Total price: 8”.
reduce goes through the cart and adds up the prices, starting from 0. The total is not stored anywhere. selectTotal works it out from the cart each time. Part 9 gave the rule: don’t store what you can work out. React’s docs say the same, in “Choosing the State Structure”. A stored total could stop matching the cart. A worked-out total can’t.
Both selectors give back numbers. A number is compared by value, so 8 is always the same as 8. A selector that gives back a new array, like state.cart.filter(...), is different. Part 25 showed that it makes React loop until it stops with an error.
Now press “Hide the cart”, then “Show the cart”. The panel comes back with “Items: 2”. The cart lives in the store, not in CartPanel. So it doesn’t matter that CartPanel left the page. We also tried a panel that keeps its own cart in useState. After hide and show, it said “Items: 0”. Part 4 explained why: state in a component goes away with the component.
Hear every change: a logger
subscribe isn’t only for components. Any code can listen. Here are two listeners that are not components. One logs each change. The other saves the state, which the next section explains.
import { useSyncExternalStore } from 'react'
type ShopState = { user: string; cart: string[] }
function createStore<T>(first: T) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: T) => T) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
const KEY = 'shop'
const empty: ShopState = { user: 'Ana', cart: [] }
// Turns saved text back into state. Gives null if the text is broken
// or doesn't have the shape we expect.
function parse(text: string): ShopState | null {
try {
const data = JSON.parse(text)
if (typeof data.user === 'string' && Array.isArray(data.cart)) return data
} catch {
// The text is not JSON.
}
return null
}
function load(): ShopState {
const text = localStorage.getItem(KEY)
if (text === null) return empty
return parse(text) ?? empty
}
const store = createStore<ShopState>(load())
// A logger: it hears about every change.
let last = store.getState()
store.subscribe(() => {
const next = store.getState()
console.log('cart:', last.cart.length, '->', next.cart.length)
last = next
})
// Saving: write every change to localStorage.
store.subscribe(() => {
localStorage.setItem(KEY, JSON.stringify(store.getState()))
})
function useStore<T>(selector: (state: ShopState) => T): T {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()))
}
function addToCart(item: string) {
store.setState(old => ({ ...old, cart: [...old.cart, item] }))
}
function CartBadge() {
const count = useStore(state => state.cart.length)
console.log('CartBadge renders')
return <p>Cart: {count}</p>
}
export default function App() {
return (
<div>
<CartBadge />
<button onClick={() => addToCart('apple')}>Add an apple</button>
</div>
)
}
CartBadge renders
CartBadge renders
cart: 0 -> 1
CartBadge renders
CartBadge renders
Run it and click once. The logger prints cart: 0 -> 1.
The logger keeps the last state in last. When the store calls it, it reads the new state and prints both. Because state is never changed in place, last still holds the old state.
Look at two things in the Console. The logger’s line appears once, not twice. Strict Mode calls components twice. But the logger isn’t a component. Code at the top of the module subscribed it once, so it prints once for each change. And the logger’s line comes before CartBadge renders. The store calls the logger right away, inside setState. React renders CartBadge a moment later.
This is a tiny version of what developer tools for state do. Redux Toolkit’s configureStore lets you use the Redux DevTools Extension, a browser add-on for looking at a store’s changes. Zustand has a devtools add-on for the same extension.
Saving to localStorage
The second listener saves the whole state on every change. Part 16 introduced localStorage: a place in the browser that keeps text under a name, called a key. MDN says the keys and values are strings. So we turn the state into text with JSON.stringify before we save it. After one click, localStorage held this text under the key shop:
{"user":"Ana","cart":["apple"]}
load() reads it back when the module first runs. getItem gives null when nothing is saved yet. Then we start with empty.
parse turns the text back into an object with JSON.parse. But saved text can be broken, for example if a person changed it by hand. With broken text, JSON.parse throws an error. We tried it with the text {bad. It threw a SyntaxError. The try and catch catch it, and parse gives null. Then the app starts with an empty cart and shows “Cart: 0” instead of crashing.
Saved text can also be good JSON with the wrong shape. An older version of your app might have saved the cart under another name, like items. So parse checks the shape too: user must be a string, and Array.isArray(data.cart) must be true. We tried saved text with items and no cart. With the check, the app started with “Cart: 0”. Without it, the app crashed with Cannot read properties of undefined (reading 'length').
In the playground, localStorage is not the real one. It lives in memory and is emptied on every Run. So the cart always starts at 0 there. We checked what a real reload would do: we ran the app a second time without emptying storage. It started with “Cart: 1”.
Our check only throws away data with the wrong shape. A real app may want to change old data into the new shape. Zustand’s persist add-on has options for this, for saved data from an older version of the app.
Two browser tabs: the storage event
Say a person has your shop open in two browser tabs. They add an apple in the first tab. The first tab saves the cart to localStorage. But the second tab’s store still holds the old cart.
The browser can tell the second tab. MDN says the storage event fires “when another document that shares the same storage area” changes it. It adds: “The event is not fired on the window that made the change.” Here, a document is one open page.
So each tab listens for the event and copies the new state into its store. It uses parse and empty from the app above:
type ShopState = { user: string; cart: string[] }
declare const store: { setState: (update: (old: ShopState) => ShopState) => void }
declare function parse(text: string): ShopState | null
declare const empty: ShopState
const KEY = 'shop'
window.addEventListener('storage', event => {
if (event.key !== KEY && event.key !== null) return
const next = event.newValue === null ? empty : parse(event.newValue)
if (next !== null) store.setState(() => next)
})
event.key is the key that changed. event.newValue is the new text. MDN says the key is null when storage was cleared with clear(). And newValue is null when the item was removed or storage was cleared. So the handler does three things:
- An event for another key: it does nothing.
- The saved cart was removed or cleared in another tab: this tab goes back to
emptytoo. - New text:
parsereads it. If the text is broken or has the wrong shape,parsegivesnull, and the store keeps what it has.
Then setState calls the listeners. The components update. The saving listener writes the same text back to localStorage. Will that start a new event in the first tab? No. The HTML standard lists the steps for setItem. They stop early when the old text is the same as the new text. So saving the same text does nothing, and no event is sent.
This code can’t run in the playground. It needs two real tabs, and the playground’s storage is in memory. So we tested the handler a different way. We added it to the app above and sent it made-up storage events. A cart of two items took the page from “Cart: 0” to “Cart: 2”. An event for a different key changed nothing, and so did broken text. An event with newValue set to null took the page back to “Cart: 0”. So did one with key and newValue both null, as clear() sends.
The cost of a store at module level
A store at module level is short and fast. But one store for the whole module has four costs.
Every user of the module shares it
import { useSyncExternalStore } from 'react'
function createStore<T>(first: T) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: T) => T) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
const counter = createStore({ count: 0 })
function Counter({ label }: { label: string }) {
const count = useSyncExternalStore(counter.subscribe, () => counter.getState().count)
return (
<button onClick={() => counter.setState(old => ({ count: old.count + 1 }))}>
{label}: {count}
</button>
)
}
export default function App() {
return (
<div>
<Counter label="Left" />
<Counter label="Right" />
</div>
)
}
Run it and click “Left”. Both buttons say 1. There is only one counter, so the two Counter components can’t have their own counts.
For a cart, that is what you want. For a part of the page that you use twice, it is a bug. Part 25 put the store in context for this reason. There, each provider made its own store.
It stays after components leave
You saw this with “Hide the cart”. It is good for a cart. But some state should go away. When a person signs out, the store still holds their cart until your code empties it. If you save the store, localStorage still holds it too. So write an action that clears both, and call it when they sign out:
type ShopState = { user: string; cart: string[] }
declare const store: { setState: (update: (old: ShopState) => ShopState) => void }
declare const empty: ShopState
const KEY = 'shop'
function signOut() {
store.setState(() => empty)
localStorage.removeItem(KEY)
}
The order matters. setState calls the saving listener, which writes the state to localStorage again. So remove the key after setState. We tried it in the logger app: after signOut(), getItem('shop') gave null. With the two lines the other way round, the saved text was back: {"user":"Ana","cart":[]}.
Tests need a reset
A test is code that runs your code and checks the result. Vitest, a test tool, gives each test file its own copy of your modules. But tests in the same file share one copy of the module, and one store.
We tried it with Vitest 5.0.3, in a test file with two tests. Test 1 added an apple. Test 2 checked that the cart starts empty. It failed: expected 1 to be +0. A test in another file started with an empty cart, as expected. So give the store a reset:
declare function createStore<T>(first: T): {
getState: () => T
setState: (update: (old: T) => T) => void
subscribe: (listener: () => void) => () => void
}
type ShopState = { user: string; cart: string[] }
const empty: ShopState = { user: 'Ana', cart: [] }
export const store = createStore<ShopState>(empty)
export function resetStore() {
store.setState(() => empty)
}
Call resetStore() before each test, or after each one. Either works, as long as every test gets a clean store. We tried both, with Vitest’s beforeEach and afterEach. Both times, test 2 passed. Zustand’s testing guide does the same thing for its stores. It sets them back to the start “after each test”.
On a server, one store serves everyone
Some apps build their first HTML on a server, as Part 13 mentioned. One server answers many people. It loads each module once, so one store at module level would be shared by all of them. Next.js is a framework that renders on a server. Zustand’s guide for Next.js says the store “should not be shared across requests”. A request is one visit asking the server for a page.
We tried it with React’s renderToString, which builds HTML on a server. Request 1 was Ben’s. During it, his cart went into the store. Request 2 was for Ana. It showed “Hello, Ben!” and “Cart: 2”.
If your app renders on a server, make one store per request. A provider that makes its own store, as in Part 25, is one way.
A note for every store, not only this one
Server rendering needs more from any store read with useSyncExternalStore, with a provider or without one. The hook needs a third function for the server, getServerSnapshot. React’s docs say that without it, “rendering the component on the server will throw an error”. We rendered the “Try this first” app with renderToString, and it threw this error:
Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.
The docs also say the store’s data must “match between the server and client”. After the HTML arrives, React makes it work in the browser. The first values there must be the same as on the server. And a browser-only API “does not exist on the server”. localStorage is one.
When to use a library
Our store is about 20 lines. That is enough for many apps. Libraries add more. Zustand’s docs list hard cases it was built to handle, such as React’s concurrent rendering from Part 25. Redux Toolkit and Zustand work with developer tools. Zustand has an add-on that saves the store. Three well-known libraries:
- Redux Toolkit describes itself as the official set of tools for writing Redux. It comes with good settings from the start. Redux changes state with reducers and actions, like Part 15.
- Zustand describes itself as small and fast. It is the closest to what we built. Its docs say “no providers are needed”. You read the store through a hook with a selector. Part 25 showed that its hook calls
useSyncExternalStore. - Jotai is a state library for React that builds state from small pieces called atoms. Its docs say: “An atom represents a piece of state.” You can make as many atoms as you want.
The kata for this part shows Zustand with import create from 'zustand'. Zustand’s docs today use import { create } from 'zustand'. The playground can only load React, so we won’t run any of these here.
Before you add any of them, look back at What “global state” means. Is most of your shared state server data? Then a library like TanStack Query may remove more code than a state library would.
Common mistakes
Putting everything in the global store
Here, a menu’s open flag lives in the store:
import { useState, useSyncExternalStore } from 'react'
function createStore<T>(first: T) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: T) => T) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
const store = createStore({ menuOpen: false })
function Menu() {
const open = useSyncExternalStore(store.subscribe, () => store.getState().menuOpen)
return (
<div>
<button onClick={() => store.setState(old => ({ ...old, menuOpen: !old.menuOpen }))}>
Menu
</button>
{open && <p>Home, Shop, Help</p>}
</div>
)
}
export default function App() {
const [show, setShow] = useState(true)
return (
<div>
<button onClick={() => setShow(!show)}>{show ? 'Hide' : 'Show'} the menu bar</button>
{show && <Menu />}
</div>
)
}
Run it. Open the menu, then hide the menu bar and show it again. The menu is still open. Most people expect a menu to start closed when it appears again.
Only Menu uses this flag. It is local state. The fix: const [open, setOpen] = useState(false) inside Menu. We tried it. After hide and show, the menu was closed. Each flag you make global also adds a selector that runs on every store change.
Keeping server data in the store
type ShopState = { products: string[] }
declare const store: { setState: (update: (old: ShopState) => ShopState) => void }
async function loadProducts() {
const res = await fetch('/api/products')
const products: string[] = await res.json()
store.setState(old => ({ ...old, products }))
}
This works at first. But the store now holds a copy of the server’s data, and nothing keeps it fresh. There is no loading state, no error state, and no Retry. Part 13 showed how much code those need.
The fix: keep server data out of your global store. Load it near where it is used, as Part 13 did. In a bigger app, use a library that caches it, like TanStack Query or SWR.
Changing the state in place
import { useSyncExternalStore } from 'react'
type ShopState = { cart: string[] }
function createStore<T>(first: T) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: T) => T) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
const store = createStore<ShopState>({ cart: [] })
function useStore<T>(selector: (state: ShopState) => T): T {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()))
}
function addToCart(item: string) {
store.getState().cart.push(item)
store.setState(old => old)
}
function CartBadge() {
const count = useStore(state => state.cart.length)
return <p>Cart: {count}</p>
}
function CartList() {
const cart = useStore(state => state.cart)
return <p>In the cart: {cart.join(', ')}</p>
}
export default function App() {
return (
<div>
<CartBadge />
<CartList />
<button onClick={() => addToCart('apple')}>Add an apple</button>
</div>
)
}
Run it and click once. The badge says “Cart: 1”. But the list says “In the cart:” with nothing after it. The page disagrees with itself.
push changed the old cart array. Then setState called the listeners. React ran both selectors. CartBadge‘s selector gave 1, and last time it gave 0, so the badge rendered. CartList‘s selector gave the cart array. It was the very same array as last time, so by Object.is nothing changed, and React skipped the list.
The fix: make a new array, as Part 5 showed. store.setState(old => ({ ...old, cart: [...old.cart, item] })). We tried it. After one click, the page said “Cart: 1” and “In the cart: apple”.
Selecting the whole state
type ShopState = { user: string; cart: string[] }
declare function useStore<T>(selector: (state: ShopState) => T): T
function Greeting() {
const state = useStore(state => state)
return <p>Hello, {state.user}!</p>
}
This gives Greeting the whole state. Each change makes a new state object, so Greeting renders on every change. We tried it in the “Try this first” app. Three cart clicks made Greeting render 3 more times with Strict Mode off, and 6 with it on. The name never changed.
The fix: select only what the component shows, state => state.user.
A selector that makes a new object
useStore(state => ({ name: state.user })) makes a new object every time. React warns The result of getSnapshot should be cached to avoid an infinite loop, then stops with Maximum update depth exceeded. We tried it in the “Try this first” app and got both. Part 25 gave three ways to fix it.
Practice
Use the app from Try this first. Press Edit there.
- Click “Add an apple” 3 times. In total, how many
Greeting renderslines are in the Console? How manyCartBadge renderslines? - Add an action,
function emptyCart() { store.setState(old => ({ ...old, cart: [] })) }. InProductList, put the button and a new<button onClick={emptyCart}>Empty the cart</button>inside a<div>. Click “Add an apple” twice, then “Empty the cart”. Which lines appear after the empty click? Then click “Empty the cart” again. What appears now? - Undo task 2. In
ProductList, put a second<CartBadge />above the button, inside a<div>. Click “Add an apple” once. How manyCartBadge renderslines appear after the click? - Undo task 3. Right after
const store = ..., addstore.subscribe(() => console.log('cart is now', store.getState().cart.length)). Click once. What does the Console show after the first render, and in what order?
Answers
- Two
Greeting renderslines, both from the first render, which Strict Mode runs twice. The clicks change only the cart, soGreeting‘s selector keeps giving'Ana'. There are 8CartBadge renderslines: 2 from the first render, and 2 for each of the 3 clicks. - After “Empty the cart”:
CartBadge renders, twice. The badge says “Cart: 0”. After the second “Empty the cart”: nothing. The cart was already empty.emptyCartmakes a new empty array, but the selector gives back a number,0.0is the same as0, so React skipsCartBadge. - Four. Both badges are subscribed, and both numbers change from 0 to 1. Each badge renders once, and Strict Mode doubles each one. The page shows “Cart: 1” twice.
- First
cart is now 1, once. ThenCartBadge renders, twice. The new listener is not a component, so Strict Mode doesn’t double it. The store calls it right away, insidesetState. React rendersCartBadgea moment later.
Interview questions
Try to answer each one out loud before you open the answer.
What is global state? Give examples of state that should not be global.
Global state is state that many parts of an app need, often far apart in the tree. Examples are the signed-in user, the theme, the language and a cart. Local state, like a menu that is open, stays in its component. Form text belongs to the form. Some state belongs in the URL, like a search word. Server data is a copy of the server’s data, a cache. A library like TanStack Query or SWR handles it better.
A strong answer adds that once server data moves out, the global state left is often very small. TanStack Query’s docs make this point with an example.
Several components need the same state. What do you try, in what order?
Keep it local if only one component needs it. Lift it to the closest common parent if a few nearby components need it. If props pass through components that don’t use them, pass JSX as children. Then a provider with context and a reducer. Last, a store outside React, read with useSyncExternalStore.
A strong answer quotes React’s docs: “Start by passing props”, and for stores, use built-in state “when possible”.
How can a store work with no provider at all?
The store is made at the top level of a module. A module’s top-level code runs once, so there is one store. Every file that imports it gets the same object. Components connect with useSyncExternalStore(store.subscribe, () => selector(store.getState())). React subscribes after the component is on the page and removes the listener when it leaves. On each store change, React runs the selector and renders the component only if the answer is different by Object.is. The component still renders when its parent renders.
A strong answer adds that store.subscribe is the same function every time. So React doesn’t subscribe again when a component renders. It also says why the hook exists. It guards against tearing, as Part 25 explained. Tearing is when one page shows two values for the same data.
What does a store at module level cost you?
Everything that imports the module shares one store. So two copies of one component can’t have their own state. The state stays after components leave, so you must reset it yourself. When a person signs out, empty the store and remove its saved copy too. Tests in the same file share the store too, so each test needs a reset. On a server, one module serves many people, so a module store would mix their data.
A strong answer gives the fix. Put the store in context, with one provider for each copy, each test or each request.
How would you save a store, and keep two browser tabs showing the same state?
Subscribe a listener that writes JSON.stringify(state) on every change. When the module loads, read the saved text and JSON.parse it. Do that inside try and catch, in case the text is broken, and check its shape. For tabs, listen for the window’s storage event. It fires in the other tabs, not in the tab that made the change. Check event.key, then copy event.newValue into the store. If newValue is null, the saved copy was removed, so go back to the empty state.
A strong answer adds that saved data can come from an older version of the app. It also says that writing the same text back doesn’t send another event.
Why must store updates make new objects instead of changing the old ones?
React decides whether to render a component by comparing its selector’s answer with the last one, using Object.is. Say you change the old array in place. A selector that returns that array gives back the same object, so React skips the component. Other selectors, like a count, still see a change. So parts of the page can disagree with each other. The comparison only decides renders that come from the store. A component still renders when its parent renders.
A strong answer adds that this is the rule from Part 5. React’s docs for useSyncExternalStore also say the snapshot must never be changed.
When would you choose Redux Toolkit, Zustand or Jotai over your own store?
When you want what they add: code that handles hard cases, developer tools, and add-ons for things like saving. Redux Toolkit is the official way to write Redux, with reducers and actions. Zustand is a small store outside React, read with selectors and no provider, much like the one in this part. Jotai builds state from small pieces called atoms.
A strong answer first checks how much of the shared state is really server data. For that, a caching library may be the better first step.
Sources
- useSyncExternalStore, react.dev:
subscribeandgetSnapshot, a snapshot that “must be immutable” (never changed), data that must “match between the server and client”, “When possible, we recommend using built-in React state”, “mostly useful if you need to integrate with existing non-React code”, re-subscribing whensubscribechanges, thetodosStoreimported from its own file, andgetServerSnapshotwith “rendering the component on the server will throw an error”. - Passing Data Deeply with Context, react.dev: “Start by passing props”, passing JSX as children, and “If neither of these approaches works well for you, consider context.”
- Sharing State Between Components and Scaling Up with Reducer and Context, react.dev: lifting state up and the provider pattern, for steps 2 and 4.
- Choosing the State Structure, react.dev: “Avoid redundant state”.
- You Might Not Need an Effect, react.dev: subscribing to an external store with
useSyncExternalStore. - Does TanStack Query replace Redux, MobX or other global state managers?, TanStack Query docs: server state and client state, the six-field example, and “usually very tiny”.
- JavaScript modules, MDN: “Modules are only executed once”.
- Window: localStorage, Window: storage event and StorageEvent, MDN: keys and values are strings, when the event fires and where it doesn’t, and
keyandnewValue. - Web storage, the HTML standard: the
setItemsteps, which stop early when the old value is the same as the new one. - Hot Module Replacement API, Vite docs: an edited module comes back to the code that accepts it as the updated module.
- Redux Toolkit README: its one-line description, and
configureStoreenabling “use of the Redux DevTools Extension”. - Zustand README, and its guides Setup with Next.js, Testing and persist: its description, “no providers are needed”,
import { create }, thedevtoolsadd-on, one store per request, resetting stores after each test, and “versioning and migrations”. - Jotai on GitHub: its description and “An atom represents a piece of state.”
- The render counts, the listener count, the console output, the saved text, the shape check,
signOut, thestorageevent tests, the shared counter, the server rendering error and the request test come from running React 19.3.0 for this post, with Strict Mode on and off. The module test ran in Node. The test files ran with Vitest 5.0.3. - This part follows the Global State Patterns kata in react-katas.