Control re-render granularity
Reactra stores have two re-render granularities, and which one you get is decided by how you bind the store:
- field-selected
inject store X { a, b }→ fine (per-field, Zustand-style bail-out viauseSyncExternalStoreWithSelector); - namespace
inject store X(member accessX.a) → coarse (whole-store, Context-style — re-renders on any write).
This page explains what that means, why it’s true, and when it matters — with the
equivalent code in React Context, lifted useReducer, Zustand, Redux Toolkit,
Jotai, and Reactra.
If you only remember one thing: a “re-render” is triggered by a subscription (plus, for the fine model, an equality check on the selected slice), and libraries differ in how narrow a thing you can subscribe to.
The setup we’ll reuse
Section titled “The setup we’ll reuse”One store, two unrelated fields, two components that each read only one:
store: { count: number, name: string }
<CountView/> reads count → shows {count}<NameView/> reads name → shows {name}The question that defines granularity:
When
countchanges, does<NameView/>re-render?
- It shouldn’t have to —
NameViewdoesn’t readcount. - Whether it does is entirely about what each component is subscribed to.
“Re-render” here means React calls the component function again. It’s usually cheap (React still diffs and often bails out before touching the DOM), but on a large or hot tree the wasted function calls + diffing add up. Granularity is about avoiding the wasted calls.
Two models
Section titled “Two models”Subscribe-to-the-whole-thing (coarse). The component is notified on any
change to the store/context, then reads the field it wants. Unrelated changes
still wake it. → React Context, lifted useReducer, Reactra namespace access
(inject store X → X.field).
Subscribe-to-a-slice (fine). The component names a slice (a selector, or a
field list) and is notified only when that slice changes. Unrelated changes are
filtered out before re-render. → Zustand, Redux (useSelector), Jotai (atoms),
Reactra field-selected inject store X { a, b }.
Both are built on the same React primitive these days
(useSyncExternalStore); the fine-grained ones use its selector-aware variant
(useSyncExternalStoreWithSelector) and an equality check. That’s the entire
technical difference.
The same thing, six ways
Section titled “The same thing, six ways”1. React Context + useReducer — coarse
Section titled “1. React Context + useReducer — coarse”const Ctx = createContext<{ state: State; dispatch: Dispatch<Action> } | null>(null)
function CountView() { const { state } = useContext(Ctx)! // subscribes to the WHOLE context value return <p>{state.count}</p>}function NameView() { const { state } = useContext(Ctx)! // also the whole value return <p>{state.name}</p>}useContext re-renders a consumer whenever the context value changes
(Object.is). The value is { state, dispatch }; any dispatch that produces a
new state is a new value → both CountView and NameView re-render, even
when only count changed. This is the classic “Context isn’t a state manager”
caveat. Mitigations: split into multiple contexts (a CountCtx and a NameCtx),
or memoize subtrees with React.memo.
2. Lifted useState/useReducer + props — coarse (and re-renders the subtree)
Section titled “2. Lifted useState/useReducer + props — coarse (and re-renders the subtree)”function App() { const [state, dispatch] = useReducer(reducer, init) return <><CountView count={state.count} /><NameView name={state.name} /></>}The owner re-renders on every change and re-renders its children unless they’re
React.memo’d with stable props. Even memoized, you’re hand-managing prop
identity. Coarse by default.
3. Zustand — fine (selector opt-in)
Section titled “3. Zustand — fine (selector opt-in)”const useStore = create<State>(() => ({ count: 0, name: "ada" }))
function CountView() { const count = useStore((s) => s.count) // subscribes to s.count ONLY return <p>{count}</p>}function NameView() { const name = useStore((s) => s.name) // subscribes to s.name ONLY return <p>{name}</p>}useStore(selector) re-renders the component only when the selected value
changes (Object.is by default; pass a shallow comparator for object slices).
Change count → only CountView re-renders. No provider; the store is a module
singleton.
4. Redux Toolkit — fine (selector via useSelector)
Section titled “4. Redux Toolkit — fine (selector via useSelector)”function CountView() { const count = useSelector((s: RootState) => s.counter.count) // subscribes to that slice return <p>{count}</p>}react-redux’s useSelector runs your selector after every dispatch and
re-renders only if the result changed (strict ===; use shallowEqual or a
memoized reselect/createSelector for derived/object results). There is a
<Provider store={store}>, but — crucially — the provider does not drive
re-renders; the per-component selector subscription does. So Redux is fine-grained
despite having a provider. Derived data is memoized with createSelector so
consumers don’t recompute or re-render unless inputs change.
5. Jotai — fine (atomic, bottom-up)
Section titled “5. Jotai — fine (atomic, bottom-up)”const countAtom = atom(0)const nameAtom = atom("ada")const remainingAtom = atom((get) => /* derived from get(countAtom) */)
function CountView() { const [count] = useAtom(countAtom) // subscribes to countAtom return <p>{count}</p>}The unit of subscription is the atom. A component re-renders only when an atom
it reads (or a derived atom whose dependencies changed) updates. Writing
countAtom never touches NameView because it never read countAtom. Derived
atoms recompute and notify only their dependents. The optional <Provider> scopes
a store; it doesn’t broadcast.
6. Reactra — fine when you select fields, coarse for namespace access
Section titled “6. Reactra — fine when you select fields, coarse for namespace access”Reactra has two access forms, and they have different granularity.
Field-selected (braces) → fine. This is the common form:
export component CountView { inject store demoStore { count } // selects `count` view { <p>{count}</p> }}export component NameView { inject store demoStore { name } // selects `name` view { <p>{name}</p> }}inject store demoStore { count } compiles to a per-field subscription:
const { count } = useReactraStoreFields<demoStore>("demoStore", ["count"])// ▲ the source-key list ["count"] is the re-render gateand useReactraStoreFields wraps React’s selector-aware primitive:
// @reactra/storereturn useSyncExternalStoreWithSelector( subscribe, getSnapshot, getServerSnapshot, (snap) => pick(snap, fields), // selector: project ["count"] shallowEqualOverFields, // isEqual: Object.is per selected field)An action still calls notify() (it fans out to all subscribers — the
subscription isn’t narrowed), but the selector + equality check run in React:
when name changes, CountView’s selector still returns { count: <same> },
which is shallow-equal to the previous selection, so React bails out — no
re-render. Change count → only CountView re-renders. This is the same
per-slice model as Zustand/Redux useSelector, with the slice named declaratively
by the { count } brace list instead of a hand-written s => s.count.
Namespace access (no braces, or as Y) → coarse. When you bind the whole
store and reach into it with member access, the slice can’t be known statically:
export component Dashboard { inject store demoStore // → const demoStore = useReactraStore("demoStore") view { <p>{demoStore.count}</p> } // member access — any field could be read}This compiles to the whole-store useSyncExternalStore(subscribe, getSnapshot),
so it re-renders on any store write — the Context-style granularity from #1.
Use the brace form when you want the per-field bail-out; use namespace access when
you genuinely read many fields or prefer the store.field style and don’t mind
coarse.
At a glance
Section titled “At a glance”| Approach | Subscription unit | count change re-renders NameView? | Provider? |
|---|---|---|---|
| React Context | whole context value | yes | yes |
Lifted useReducer + props | owner + children | yes (unless memo’d) | n/a |
Reactra — inject store X { a } (field-selected) | selected field list | no | no (registry) |
Reactra — inject store X (namespace, X.a) | whole store snapshot | yes | no (registry) |
| Zustand | selector result | no | no |
Redux + useSelector | selector result | no | yes (inert for re-renders) |
| Jotai | atom | no | optional |
So Reactra spans both columns: the field-selected brace form is fine (alongside Zustand/Redux/Jotai); namespace member access is coarse (alongside Context). Pick per binding.
How to get fine granularity (the default for field selection)
Section titled “How to get fine granularity (the default for field selection)”The win is automatic when you select fields — no selector to write, memoize, or get wrong:
inject store X { a, b }gives youaandbAND subscribes per-field — the read list is the selector. The compiler emitsuseReactraStoreFields("X", ["a", "b"])with a shallow-equality bail-out over those source keys.- Derived values live in the store and are recomputed in the snapshot composer —
one place, not a
createSelectorgraph per consumer. Select a derived field by name like any other ({ total }) to subscribe to it. - The whole store is still one coherent, debuggable snapshot; selection only gates which consumers re-render, not how state is stored.
So the brace form gives you Zustand/Redux-grade granularity with Context-grade authoring simplicity.
When you’re still coarse — and what to do
Section titled “When you’re still coarse — and what to do”You’re coarse only when you use namespace access (inject store X → X.field):
member access can’t be statically narrowed to a field list, so the consumer
subscribes to the whole store and re-renders on any write. This bites when a
single store is both large and high-frequency AND consumed via namespace
access: e.g. a store holding a 5,000-row table and a cursor position that updates
on every mouse move, with many X.field subscribers.
Practical fixes, in order of preference:
- Use the brace form
{ … }instead of namespace access — name the fields a component actually reads and it subscribes per-field. This is the first and usually sufficient fix. - Split stores by change-frequency / concern. Put the hot, fast-changing field in its own store; only its consumers re-render on its ticks. Good design even with per-field selection (it also keeps snapshots small).
- Memoize expensive subtrees with
React.memoso a re-render of a parent doesn’t re-diff a heavy child whose props didn’t change. - Move genuinely render-perf-critical, atom-grained state to a library built for it (Zustand/Jotai) for that specific screen. Reactra emits plain React, so dropping a Zustand store into one component is fine — no conflict.
How to see it yourself
Section titled “How to see it yourself”- React DevTools → “Highlight updates when components render.” Toggle it, change one field, watch which components flash. Coarse models flash unrelated consumers; fine ones don’t.
- React DevTools Profiler → record an interaction → “Ranked” view shows which components re-rendered and how long they took. If unrelated consumers show up, that’s coarse granularity costing you.
- “Granularity” = how narrow a thing a component can subscribe to.
- Coarse (Context, lifted reducer, Reactra namespace
inject store X→X.field): one notification per store/context; unrelated consumers re-render (cheaply, usually). - Fine (Zustand, Redux
useSelector, Jotai, Reactra field-selectedinject store X { a, b }): per-selector / per-atom / per-field; only affected consumers re-render. - Reactra gives you both: the brace form is fine-grained per-field (the selector is the field list — no plumbing), and namespace access is coarse for when you read many fields. Pick per binding; reach for a selector library only on a rare atom-grained hot path.