A component that reads one field of a context still renders when any other field changes. Learn why, and build a small store with selectors, so a store change renders only the components whose part changed.
In Part 23 we used context to hand a value to many components at once. In Part 24 we wrapped state and its update function in a provider component. That works well. But it has a cost that grows with the app.
When a context value changes, every component that reads it renders again. That is true even when a component uses only one small field of the value. Part 20 said this part would show how to read only part of a context.
This part shows the problem, with real counts. Then it looks at the first fixes and where they stop. Then we build our own small tool, so a change in the state renders only the components whose part changed. That tool is a selector, and it is how popular state libraries work.
Try this first
Read this code. Don’t press Run yet.
import { createContext, useContext, useState, type ReactNode } from 'react'
type Shop = {
user: { name: string }
cart: string[]
addApple: () => void
}
const ShopContext = createContext<Shop | null>(null)
function ShopProvider({ children }: { children: ReactNode }) {
const [user] = useState({ name: 'Ana' })
const [cart, setCart] = useState<string[]>([])
const shop = { user, cart, addApple: () => setCart([...cart, 'apple']) }
return <ShopContext value={shop}>{children}</ShopContext>
}
function useShop() {
const shop = useContext(ShopContext)
if (!shop) throw new Error('useShop must be used inside ShopProvider')
return shop
}
function Greeting() {
const shop = useShop()
console.log('Greeting renders')
return <p>Hello, {shop.user.name}!</p>
}
function CartCount() {
const shop = useShop()
console.log('CartCount renders')
return <p>Items in the cart: {shop.cart.length}</p>
}
function AddButton() {
const shop = useShop()
return <button onClick={shop.addApple}>Add an apple</button>
}
export default function App() {
return (
<ShopProvider>
<Greeting />
<CartCount />
<AddButton />
</ShopProvider>
)
}
Greeting renders
Greeting renders
CartCount renders
CartCount renders
Greeting renders
Greeting renders
CartCount renders
CartCount renders
One context holds a user and a cart. Greeting shows only the user’s name. CartCount shows only the number of items in the cart.
Make a guess. When you click “Add an apple”, only the cart changes. Will Greeting render again?
Now press Run, click the button once, and look at the Console.
The first four 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, both components render, twice each. The name did not change. Greeting rendered anyway.
Why Greeting renders
React’s docs explain it. React “re-renders all the children that use a particular context” when the provider “receives a different value”.
Look at what ShopProvider gives. Every time it renders, it makes a new object, { user, cart, addApple }. React compares the old value and the new value with Object.is, the same test you met in Part 5. Two different objects are never the same. So every reader of ShopContext renders.
React doesn’t look inside the value. It doesn’t know that Greeting uses only user.name. It knows only that Greeting reads ShopContext, and ShopContext changed.
Part 24 kept the value the same object with useMemo. That doesn’t help here, because the cart really changed. We checked, with Strict Mode off. With the value inside useMemo, three clicks still made Greeting render 3 more times.
In this small app, an extra render costs almost nothing. But in a big app, one context might be read by fifty components. Then one small change renders all fifty.
The first fixes, and where they stop
You already know two ways to make this better.
Split the context
Part 23 split one context into smaller ones, one for each kind of data. Here, that means a UserContext and a CartContext. Greeting reads only UserContext, so a change to the cart doesn’t reach it.
Each context value must also stay the same object while its own part hasn’t changed. Part 24 did that with useMemo. We first forgot it. Then each cart change made a new user object too, and Greeting rendered 3 times after three cart changes. With useMemo on both values, it rendered 0 more times. Both counts are with Strict Mode off.
But a split is only as fine as the contexts you make. Say UserContext also holds the user’s points. We tried that. Three changes to points made Greeting render 3 more times, though it shows only the name. To stop that, you’d need a context just for the name. A big app would need many small contexts.
Pass the part you need to a memo child
Part 20 showed that memo skips a component when its props are the same. So we can split Greeting in two. The outer part reads the context. The inner part is wrapped in memo and gets only the name:
import { createContext, memo, useContext } from 'react'
const ShopContext = createContext({ user: { name: 'Ana' }, cart: [] as string[] })
const GreetingText = memo(function GreetingText({ name }: { name: string }) {
console.log('GreetingText renders')
return <p>Hello, {name}!</p>
})
function Greeting() {
const shop = useContext(ShopContext)
console.log('Greeting renders')
return <GreetingText name={shop.user.name} />
}
We tried this in the shop app, with Strict Mode off. After three clicks, Greeting rendered 3 more times. GreetingText rendered 0 more times.
That helps when the inner part is big. But the outer part still renders on every change, and you write two components each time. Note that the memo is on the part that does not read the context. React’s docs say memo “does not prevent the children receiving fresh context values”.
The selector idea
What we really want is this. A component says which part of the state it needs. React gives it that part. And when the state changes, React renders the component again only if that part changed.
The part is chosen by a small function. It gets the whole state and gives back one piece:
type ShopState = { user: { name: string }; cart: string[] }
const selectName = (state: ShopState) => state.user.name
const selectCount = (state: ShopState) => state.cart.length
A function like this is called a selector, because it selects the part you need.
The rule for “the part changed” is the same one React uses everywhere: Object.is. When the cart changes, selectName still gives 'Ana'. Object.is('Ana', 'Ana') is true, so the component that uses it has nothing new to show.
An everyday example
Think of a school notice board. Many students read it, and the teacher changes it often. Without selectors, every student walks to the board each time anything changes, and reads it all.
With selectors, each student tells the teacher one thing they care about, like the football times. When the board changes, the teacher checks the football times. The student gets a message only if they changed.
The exact version
The teacher in this story still checks every student’s part on every change. A selector is not free. Each one runs each time the state changes. That is fine when selectors are small and fast, like state.user.name. A slow selector, run on every change, can add up.
Why useContext can’t do this
useContext takes a context and gives back the whole value. It has no place for a selector. In React 19.3, there is no hook that reads only part of a context. We checked: React has no export with “Selector” in its name.
People have asked for one. In 2019, a request for comments (an RFC, a written plan for a new React feature) proposed a useContextSelector hook. React has not added it. A library called use-context-selector copies the idea outside React. But its own page says that if you only want to avoid re-renders, it recommends other tools. One of them is a “naive implementation” with useSyncExternalStore. Naive here means simple, with no extras. That is what we build next.
Building it yourself
The plan has three pieces.
- A store: a plain object that holds the state, outside React. It also keeps a list of functions to call when the state changes.
- A context that gives the store to every component. The store object never changes, so the context value never changes either.
- A hook,
useStoreSelector, that runs a selector and renders the component only when its answer changes.
Here is the whole app. Read it first, then we’ll go through it piece by piece.
import { createContext, useContext, useState, useSyncExternalStore, type ReactNode } from 'react'
type ShopState = {
user: { name: string }
cart: string[]
}
function createShopStore(first: ShopState) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: ShopState) => ShopState) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
type ShopStore = ReturnType<typeof createShopStore>
const StoreContext = createContext<ShopStore | null>(null)
function ShopProvider({ children }: { children: ReactNode }) {
const [store] = useState(() => createShopStore({ user: { name: 'Ana' }, cart: [] }))
return <StoreContext value={store}>{children}</StoreContext>
}
function useStore() {
const store = useContext(StoreContext)
if (!store) throw new Error('useStore must be used inside ShopProvider')
return store
}
function useStoreSelector<T>(selector: (state: ShopState) => T): T {
const store = useStore()
return useSyncExternalStore(store.subscribe, () => selector(store.getState()))
}
function Greeting() {
const name = useStoreSelector(state => state.user.name)
console.log('Greeting renders')
return <p>Hello, {name}!</p>
}
function CartCount() {
const count = useStoreSelector(state => state.cart.length)
console.log('CartCount renders')
return <p>Items in the cart: {count}</p>
}
function AddButton() {
const store = useStore()
function addApple() {
store.setState(old => ({ ...old, cart: [...old.cart, 'apple'] }))
}
return <button onClick={addApple}>Add an apple</button>
}
export default function App() {
return (
<ShopProvider>
<Greeting />
<CartCount />
<AddButton />
</ShopProvider>
)
}
Greeting renders
Greeting renders
CartCount renders
CartCount renders
CartCount renders
CartCount renders
Press Run and click “Add an apple” once. The page looks the same as before. But the Console is different. After the click, only CartCount renders appears, twice because of Strict Mode. Greeting did not render.
Piece 1: the store
createShopStore makes a store. It is plain JavaScript. There is no React in it.
stateholds the current state. It is a normal variable, not React state.listenersis aSetof functions. A listener is a function that wants to hear about changes. ASetis like an array that never holds the same thing twice.getState()gives back the current state.setState(update)makes the new state from the old one, then calls every listener. That is how the store says “something changed”.subscribe(listener)adds a listener. It gives back a function that removes it again. Part 12 called these subscribe and unsubscribe.
The update we pass to setState follows the rule from Part 5. It never changes the old state. It makes a new object. In addApple, { ...old, cart: [...old.cart, 'apple'] } copies the old state, with a new cart. The user object is copied over as it is. So after the click, state.user is still the very same object as before.
type ShopStore = ReturnType<typeof createShopStore> asks TypeScript for the type of whatever createShopStore returns. So we don’t have to write the store’s type out by hand.
Piece 2: the store goes into context
ShopProvider makes the store once. useState(() => ...) runs that function only on the first render. Part 4 showed this function form. Nothing ever sets this state again. So store stays the same object while the provider is on the page.
Then <StoreContext value={store}> gives the store to the components below. The context value is the store itself, not the state inside it. The store is always the same object. So the context value never changes, and the context never makes anyone render.
Why use context at all, if the value never changes? So that each ShopProvider gets its own store. We tried two providers side by side. A click in the first one changed only the first one’s count.
Piece 3: useStoreSelector and useSyncExternalStore
useSyncExternalStore is a React hook for reading from a store that lives outside React. React’s docs call it an external store. It takes two functions, and an optional third one, getServerSnapshot, for server rendering. React’s docs say that without it, rendering the component on the server throws an error. Our app runs only in the browser, so we leave it out.
subscribe: React calls it with a callback (a function for the store to call later). The store must call that callback when it changes. Ourstore.subscribedoes that. It must also stay the same function from render to render. React’s docs say that if a differentsubscribeis passed, React “will re-subscribe to the store”. Ours is made once, with the store, so it never changes. We tried a new function made in each render instead,callback => store.subscribe(callback). Three clicks made three extra subscribe calls. Withstore.subscribepassed as it is, there were none.getSnapshot: React calls it to read the current value. Ours is() => selector(store.getState()). It reads the state and runs the selector.
React’s docs say what happens when the store calls the callback. React calls getSnapshot again. “If the store changes and the returned value is different (as compared by Object.is), React re-renders the component.”
React doesn’t compare the whole state. It compares only what the selector gave back.
The <T> in useStoreSelector<T> means “some type, decided by the selector”. If the selector returns a number, useStoreSelector returns a number. If it returns text, the hook returns text.
A store with selectors: only the component whose part changed renders. Press play, or step through it.
Here are the same steps in words.
- You click “Add an apple”.
addApplecallsstore.setState. The store now holds a new cart. - The store calls each listener. There are two: one for
Greetingand one forCartCount. - React calls
Greeting‘sgetSnapshot. The selector gives'Ana', the same as last time. - React calls
CartCount‘sgetSnapshot. The selector gives1. Last time it gave0. - React renders only
CartCount. It skipsGreeting.
When does React subscribe? After the component is on the page, like an Effect. We logged it, with Strict Mode on. Both components rendered first. Then the listener count went 1, 2, then back down to 1, 0, then up to 1, 2 again. That middle part is Strict Mode’s test. Part 12 showed the same thing for an Effect: set up, clean up, set up again. In the end the store has 2 listeners, one for each component that uses a selector.
Counting the renders
We clicked “Add an apple” 3 times in each app and counted the renders after the first render.
| App | Greeting (Strict Mode off) |
CartCount (Strict Mode off) |
Greeting (Strict Mode on) |
CartCount (Strict Mode on) |
|---|---|---|---|---|
One context, useContext |
3 | 3 | 6 | 6 |
| Store with selectors | 0 | 3 | 0 | 6 |
CartCount must render: its number changed. Greeting doesn’t need to, and with selectors, it doesn’t.
A selector controls only the renders that come from the store. A component still renders when its parent renders, when its own state changes, or when another context it reads changes. We gave App its own counter and clicked it 3 times. Greeting rendered 3 more times (Strict Mode off), with no store change at all. React Redux’s docs say the same: useSelector “does not prevent the component from re-rendering due to its parent re-rendering”.
Selectors must give back stable values
There is one rule you must follow. When nothing has changed, a selector must give back the same value as last time. Not an equal value: the same one, by Object.is.
Here is what happens when you break it. Only Greeting changed from the app above. Its selector now makes a new object:
import { createContext, useContext, useState, useSyncExternalStore, type ReactNode } from 'react'
type ShopState = {
user: { name: string }
cart: string[]
}
function createShopStore(first: ShopState) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: ShopState) => ShopState) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
type ShopStore = ReturnType<typeof createShopStore>
const StoreContext = createContext<ShopStore | null>(null)
function ShopProvider({ children }: { children: ReactNode }) {
const [store] = useState(() => createShopStore({ user: { name: 'Ana' }, cart: [] }))
return <StoreContext value={store}>{children}</StoreContext>
}
function useStore() {
const store = useContext(StoreContext)
if (!store) throw new Error('useStore must be used inside ShopProvider')
return store
}
function useStoreSelector<T>(selector: (state: ShopState) => T): T {
const store = useStore()
return useSyncExternalStore(store.subscribe, () => selector(store.getState()))
}
function Greeting() {
const user = useStoreSelector(state => ({ name: state.user.name }))
console.log('Greeting renders')
return <p>Hello, {user.name}!</p>
}
function CartCount() {
const count = useStoreSelector(state => state.cart.length)
console.log('CartCount renders')
return <p>Items in the cart: {count}</p>
}
function AddButton() {
const store = useStore()
function addApple() {
store.setState(old => ({ ...old, cart: [...old.cart, 'apple'] }))
}
return <button onClick={addApple}>Add an apple</button>
}
export default function App() {
return (
<ShopProvider>
<Greeting />
<CartCount />
<AddButton />
</ShopProvider>
)
}
Press Run. Nothing shows on the page. Instead, React stops with this error:
Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
The message names old class methods. Here the cause is getSnapshot. Before the error, the Console shows a warning, and Greeting renders many, many times:
The result of getSnapshot should be cached to avoid an infinite loop
Here is what went wrong. ({ name: state.user.name }) makes a new object every time it runs. React calls getSnapshot, gets an object, then calls it again and gets a different object. To React, the store seems to change all the time. So it renders again, and again. React’s docs explain it: “if you always return a different value, you will enter an infinite loop and get this error.”
So a new object every time looks like a change every time.
This is not a check that only runs in development. We tried the production build too. It printed no warning, but the loop still ran. React stopped with a short error, number 185. React’s list of error codes says 185 is Maximum update depth exceeded.
The same happens with arrays. state => state.cart.filter(item => item === 'apple') makes a new array every time, even an empty one. We tried it, and got the same error.
Three ways to fix it
Select one plain value per call. Call the hook twice:
type ShopState = { user: { name: string }; cart: string[] }
declare function useStoreSelector<T>(selector: (state: ShopState) => T): T
function Summary() {
const name = useStoreSelector(state => state.user.name)
const items = useStoreSelector(state => state.cart.length)
return <p>{name} has {items} items.</p>
}
Text and numbers are compared by value, so 'Ana' is always the same as 'Ana'. React Redux’s docs give this as their first fix too. We tried Summary in the shop app. Three clicks made it render 3 more times (Strict Mode off), because items changed each time.
Select an object that is already in the state. state => state.user is fine. It doesn’t make anything new. It gives back the object the store already holds. And because setState copied user over as it was, it stays the same object while only the cart changes. We tried it: three clicks, and Greeting rendered 0 more times.
Compare the fields, not the object. Sometimes you really want an object with a few fields. Then you need a different test, a shallow compare. It checks that both objects have the same keys, and that each key’s value passes Object.is. Part 20 showed memo compares props in this same way.
import { createContext, useContext, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react'
type ShopState = {
user: { name: string }
cart: string[]
}
function createShopStore(first: ShopState) {
let state = first
const listeners = new Set<() => void>()
return {
getState: () => state,
setState: (update: (old: ShopState) => ShopState) => {
state = update(state)
listeners.forEach(listener => listener())
},
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
type ShopStore = ReturnType<typeof createShopStore>
const StoreContext = createContext<ShopStore | null>(null)
function ShopProvider({ children }: { children: ReactNode }) {
const [store] = useState(() => createShopStore({ user: { name: 'Ana' }, cart: [] }))
return <StoreContext value={store}>{children}</StoreContext>
}
function useStore() {
const store = useContext(StoreContext)
if (!store) throw new Error('useStore must be used inside ShopProvider')
return store
}
function useStoreSelector<T>(selector: (state: ShopState) => T): T {
const store = useStore()
return useSyncExternalStore(store.subscribe, () => selector(store.getState()))
}
function shallowEqual(a: Record<string, unknown>, b: Record<string, unknown>) {
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
return keysA.every(key => Object.is(a[key], b[key]))
}
function useStoreSelectorShallow<T extends Record<string, unknown>>(selector: (state: ShopState) => T): T {
const store = useStore()
const getSnapshot = useMemo(() => {
let last: T | null = null
return () => {
const next = selector(store.getState())
if (last !== null && shallowEqual(last, next)) {
return last
}
last = next
return next
}
}, [store, selector])
return useSyncExternalStore(store.subscribe, getSnapshot)
}
function Greeting() {
const user = useStoreSelectorShallow(state => ({ name: state.user.name }))
console.log('Greeting renders')
return <p>Hello, {user.name}!</p>
}
function CartCount() {
const count = useStoreSelector(state => state.cart.length)
console.log('CartCount renders')
return <p>Items in the cart: {count}</p>
}
function AddButton() {
const store = useStore()
function addApple() {
store.setState(old => ({ ...old, cart: [...old.cart, 'apple'] }))
}
return <button onClick={addApple}>Add an apple</button>
}
export default function App() {
return (
<ShopProvider>
<Greeting />
<CartCount />
<AddButton />
</ShopProvider>
)
}
Greeting renders
Greeting renders
CartCount renders
CartCount renders
CartCount renders
CartCount renders
Run it and click once. The crash is gone, and Greeting doesn’t render after the click.
useStoreSelectorShallow makes its own getSnapshot, with useMemo from Part 21. That function keeps the last answer in a variable, last. When the new answer has the same fields as the last one, it gives back the last object. So getSnapshot returns the same object, and it passes the Object.is test. React’s docs ask for this when a snapshot must be a new object. They say to “store the last calculated snapshot” and give it back while the store has not changed.
Why a variable inside useMemo, and not a ref? getSnapshot runs during render, and Part 14 said not to read or write a ref during render. React’s own helper for selectors, useSyncExternalStoreWithSelector, keeps its cache the same way. A comment in its code says it does not use useRef on purpose. A new selector means a new getSnapshot with an empty last. That’s fine. We tried it with a parent that rendered between store changes. Greeting rendered for the parent’s renders, and skipped every store change.
T extends Record<string, unknown> means T must be an object whose keys are text. The shallow compare needs that, so it can read each key.
A shallow compare looks only one level deep. Two objects that each hold the same cart array are equal. Two objects that each hold their own copy of ['apple'] are not, because the two arrays are different objects. We checked both.
Tearing, and why useSyncExternalStore exists
In Part 12 we subscribed to a store with useEffect and useState. Why not do that here? React’s docs say a “purpose-built Hook” for this “is preferred instead”. Here is one thing it does for you. It has to do with how React 18 and later can render.
Since React 18, React can pause in the middle of a render. It can do this when you use a feature like a Transition, which marks an update as non-blocking. Non-blocking means the user doesn’t have to wait for that update to finish. They can keep clicking while React works. React’s team calls this concurrent rendering. Concurrent means “at the same time”: React’s work and the user’s clicks go on together.
A pause makes a new problem possible. Say React has rendered half the page with the store’s old value. Then, during the pause, a click changes the store. The other half of the page renders with the new value. Now the page shows two different values for the same data. React’s team calls this tearing. In their words, it means “a UI has shown multiple values for the same state”.
useSyncExternalStore is React’s guard against this. Its docs say that during a Transition, React calls getSnapshot a second time just before it changes the page. If the value is different, React starts the update again, this time without pausing. The goal is that “every component on screen is reflecting the same version of the store.”
You get this just by using useSyncExternalStore. Our small app has no Transitions, so we can’t show tearing here. This section only repeats what React’s docs and React’s team say.
This is how state libraries work
Two popular state libraries do what we just built.
- React Redux has a
useSelectorhook. Since version 8, it is built onuseSyncExternalStore. Its docs sayuseSelectorre-renders only when the selected value is different, compared with===by default. They warn that a new object every time “will always force a re-render”. For that case they give ashallowEqualfunction. - Zustand is another state library. It keeps its store outside React too. Its
useStorehook callsuseSyncExternalStore, with agetSnapshotthat runs your selector, much like ours. For selectors that return objects, it hasuseShallow.
They differ in one detail. React Redux caches the selector’s answer for each store state, using useSyncExternalStoreWithSelector. So a selector that makes a new object gives extra renders there, not a loop. In development it also warns that the selector “returned a different result when called with the same parameters”. Zustand 5 passes your selector straight to useSyncExternalStore. Its guide says a selector that returns a new reference “may cause infinite loops”.
So in a real app you don’t need to write this yourself. But now you know what those libraries do, and why their rules exist. Part 27 looks at global state without a library.
Common mistakes
A selector that makes a new object or array
type ShopState = { user: { name: string }; cart: string[] }
declare function useStoreSelector<T>(selector: (state: ShopState) => T): T
function Greeting() {
const user = useStoreSelector(state => ({ name: state.user.name }))
return <p>Hello, {user.name}!</p>
}
TypeScript shows no error here. React does. You saw the warning and the crash above. .filter(), .map() and { ... } all make something new.
The fix: one of the three ways above.
Changing the state without telling the listeners
type Store = { getState: () => { cart: string[] } }
declare const store: Store
function AddButton() {
function addApple() {
store.getState().cart.push('apple')
}
return <button onClick={addApple}>Add an apple</button>
}
push changes the cart in place. But no listener is called, so React never asks for a new snapshot. We tried this in the shop app. After three clicks the page still said “Items in the cart: 0”. No component rendered.
Even calling the listeners isn’t enough if the old object was changed in place. We tried a setState that pushed into the old cart and returned the old state. CartCount selected state.cart, which was still the same array. Three clicks, 0 renders, and the page still said 0. If CartCount selects state.cart.length instead, the count goes up to 3. It seems to work, and that hides the bug.
The fix: always go through setState, and always make new objects, as Part 5 showed.
Subscribing during render
import { createContext, useContext, useState } from 'react'
type Store = { getState: () => { cart: string[] }; subscribe: (listener: () => void) => () => void }
const StoreContext = createContext<Store | null>(null)
function CartCount() {
const store = useContext(StoreContext)!
const [count, setCount] = useState(store.getState().cart.length)
store.subscribe(() => setCount(store.getState().cart.length))
return <p>Items in the cart: {count}</p>
}
The code in a component’s body runs on every render. So this adds a new listener on every render, and never removes any. We tried it, with Strict Mode off. The store had 1 listener after the first render, and 4 after three clicks. With Strict Mode on, it had 8. Each listener sets state, so the app does more and more work.
The fix: let useSyncExternalStore subscribe for you. It subscribes after the component is on the page. It removes its listener when the component goes away.
A subscribe that never removes the listener
import { useSyncExternalStore } from 'react'
const listeners = new Set<() => void>()
let count = 0
function subscribe(listener: () => void) {
listeners.add(listener)
}
function CartCount() {
const value = useSyncExternalStore(subscribe, () => count)
return <p>Items in the cart: {value}</p>
}
TypeScript catches this one. useSyncExternalStore expects subscribe to give back a function, and this one gives back nothing.
The playground doesn’t check types, so the mistake would run there. We tried it in the shop app, with a button that hides and shows CartCount. With Strict Mode off, the store had 1 listener at the start. After hiding and showing twice, it had 3. Only one CartCount was on the page. The old listeners stayed in the Set forever. With the unsubscribe function put back, the count stayed at 1.
The fix: subscribe must return a function that removes the listener.
Practice
Use the store app from Building it yourself. Press Edit there.
- Click “Add an apple” 3 times. In total, how many
Greeting renderslines are in the Console? - In
AddButton, add a second button:<button onClick={() => store.setState(old => ({ ...old, user: { name: 'Ben' } }))}>Make it Ben</button>. Put both buttons inside a<div>. Run it and click that button once. Which lines appear after the click? - Undo task 2. Change
Greetingto selectstate => state.user, and show{user.name}. Click “Add an apple” 3 times. How manyGreeting renderslines are there in total now? - Change
CartCountto selectstate => state.cart.filter(item => item === 'apple'), and show its.length. Run it. What happens? Then fix it so it shows the number of apples without the error.
Answers
- Two. Both come from the first render, which Strict Mode runs twice. The clicks change only the cart, so
Greeting‘s selector keeps giving'Ana', and React skips it. Greeting renders, twice (Strict Mode).CartCountdoesn’t render, because its number is still 0. The page now says “Hello, Ben!”.- Still two.
setStatecopiesuserover as it is, sostate => state.usergives back the same object after each click.Object.issays it is the same, so React skipsGreeting. - The page stays empty and React stops with
Maximum update depth exceeded. The Console showsThe result of getSnapshot should be cached to avoid an infinite loop.filtermakes a new array every time. One fix is to select the number instead:state => state.cart.filter(item => item === 'apple').length, and show it as it is. A number is compared by value, so the loop is gone. After one click the page says “Items in the cart: 1”.
Interview questions
Try to answer each one out loud before you open the answer.
A component reads only user.name from a context. Why does it render when the cart changes?
useContext gives the whole value, and React tracks the whole value. When the provider gets a different value by Object.is, React renders every component that reads that context. It doesn’t know which fields each one uses. A cart change makes a new value object, so every reader renders.
A strong answer adds that useMemo on the value doesn’t help here. The cart really did change.
How can you stop those extra renders without a library?
Split the context, so each component reads only the context it needs. Or let an outer component read the context and pass one field to a memo child. Or keep the state in a store outside React, and read it with selectors through useSyncExternalStore.
A strong answer says where each one stops. Splitting needs a context per piece. The memo trick still runs the outer component. The store needs selectors that return stable values.
What is a selector, and when does a component that uses one render again?
A selector is a function that gets the whole state and gives back one part, like state => state.cart.length. When the store says it changed, React calls getSnapshot, which runs the selector. If the answer is different by Object.is, React renders the component. If not, React skips it.
A strong answer adds that every selector runs on every store change, so selectors should be fast. It also says a selector only stops renders that come from the store. A parent render, the component’s own state or another context still render it.
Why put the store in context, and not the state itself?
The store object never changes, so the context value never changes. The context never makes anyone render. It only hands the store down. Each component then subscribes to just the part it needs.
A strong answer adds that each provider can make its own store, so a test gets a fresh one.
What happens if a selector returns a new object every time?
getSnapshot gives a different value on every call, so React thinks the store keeps changing. React 19.3 warns The result of getSnapshot should be cached to avoid an infinite loop. Then it stops with Maximum update depth exceeded. The loop happens in production too, as error 185. .filter(), .map() and { ... } all cause it.
A strong answer gives the fixes. Select plain values, one per call. Select objects the state already holds. Or use a shallow compare that gives back the last object. React Redux’s shallowEqual and Zustand’s useShallow do the last one.
What is tearing, and how does React guard against it?
Tearing is when the page shows two different values for the same data. Since React 18, React can pause during a render. If an outside store changes during the pause, two parts of the page can show different values.
React’s docs say that during a Transition, React calls getSnapshot again just before it changes the page. If the value changed, React starts the update again without pausing. A strong answer adds that React’s docs prefer useSyncExternalStore to an Effect for reading an outside store. It also names the cost: a change to an outside store can’t be marked as a Transition. If the store changes during a Transition, React does that update as a blocking one.
Sources
- useSyncExternalStore, react.dev:
subscribeandgetSnapshot, theObject.iscompare, “The result of getSnapshot should be cached”, storing the last snapshot, and the secondgetSnapshotcall during a Transition. - useContext, react.dev: every reader of a context re-renders when the provider gets a different value, and
memodoesn’t stop it. - You Might Not Need an Effect, react.dev: “Subscribing to an external store”, and why
useSyncExternalStoreis preferred over an Effect. - Passing Data Deeply with Context and Scaling Up with Reducer and Context, react.dev: context and providers, for Parts 23 and 24.
- What is tearing?, React 18 Working Group, by the React team: the meaning of tearing and why concurrent rendering makes it possible.
- RFC: Context selectors, reactjs/rfcs, 2019: the
useContextSelectorproposal. - use-context-selector: the library and its note about other ways to avoid renders.
- React Redux hooks and the React Redux 8.0 release notes: how
useSelectorcompares, calling it once per field,shallowEqual, and the move touseSyncExternalStore. - Zustand README and its
useStoresource: selectors,useShallow, and the call touseSyncExternalStore. - Object.is, MDN: how two values are compared.
- The render counts, the listener counts, the order of subscribe and unsubscribe calls, and the warning and error texts come from running React 19.3.0 for this post, with Strict Mode on and off. The TypeScript errors come from TypeScript 7.0.2.
- This part follows the Context Selectors kata in react-katas.