Fine-Grained Reactivity in React
Minimizing re-renders in large React trees using a custom Store
June 5, 2025
When building large, interactive UIs—think lists, grids, or charts—performance can quickly become a concern. One of the most effective ways to optimize React apps is to minimize unnecessary re-renders, especially in trees with many children. This post explores a pattern for fine-grained reactivity using a custom Store and selector-based subscriptions, allowing only the components that need to update to re-render.
This post is due in large part to an internal research memo written by a colleague. What I'm writing here is my attempt to store it for posterity.
The Problem: Context and Re-renders
Consider a simple list of selectable items:
const Context = React.createContext()
function List() {
const [selected, setSelected] = React.useState(0)
const context = React.useMemo(() => ({ selected, setSelected }), [selected])
return (
<Context.Provider value={context}>
{Array.from({ length: 1000 }).map((_, i) => (
<Item key={i} index={i} />
))}
</Context.Provider>
)
}
const Item = React.memo(({ index }) => {
const context = React.useContext(Context)
const isSelected = context.selected === index
return (
<button onClick={() => context.setSelected(index)}>
{index} {isSelected && '-- selected --'}
</button>
)
})
Even though Item
is wrapped in React.memo
, every single Item
re-renders when the selection changes, because the context value changes on every update.
The Solution: Store and Selectors
To avoid this, we can use a custom Store
object that never changes identity, and let components subscribe to only the state slices they care about.
Store Implementation
class Store<State> {
state: State
private listeners = new Set<(state: State) => void>()
constructor(initial: State) {
this.state = initial
}
subscribe = (fn: (state: State) => void) => {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
update = (newState: State) => {
this.state = newState
this.listeners.forEach((l) => l(newState))
}
}
useSelector Hook
function useSelector<State, Selected>(
store: Store<State>,
selector: (state: State, ...args: any[]) => Selected,
...args: any[]
): Selected {
const [value, setValue] = React.useState(() => selector(store.state, ...args))
React.useEffect(() => {
return store.subscribe((state) => setValue(selector(state, ...args)))
}, [store, selector, ...args])
return value
}
Example: Only the Selected Items Re-render
const Context = React.createContext<Store<{ selected: number }> | null>(null)
function List() {
// Store instance is stable and never changes
const storeRef = React.useRef(new Store({ selected: 0 }))
return (
<Context.Provider value={storeRef.current}>
{Array.from({ length: 1000 }).map((_, i) => (
<Item key={i} index={i} />
))}
</Context.Provider>
)
}
const isSelectedSelector = (state: { selected: number }, index: number) =>
state.selected === index
const Item = React.memo(({ index }: { index: number }) => {
const store = React.useContext(Context)!
const isSelected = useSelector(store, isSelectedSelector, index)
return (
<button onClick={() => store.update({ selected: index })}>
{index} {isSelected && '-- selected --'}
</button>
)
})
Reactive vs Non-Reactive Bindings
- Non-reactive:
const isFirstItemSelected = isSelectedSelector(store.state, 0);
- Reactive:
const isFirstItemSelected = useSelector(store, isSelectedSelector, 0);
Do not mix reactive and non-reactive values in the same render, as it can lead to inconsistent state.
Tradeoffs and Alternatives
Selector-based reactivity is simple and effective for most use-cases. For extremely high-frequency updates (e.g., scrolling thousands of cells), event-based reactivity can be even more efficient, but is more complex to implement.