Skip to content

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 via useSyncExternalStoreWithSelector);
  • namespace inject store X (member access X.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.


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 count changes, does <NameView/> re-render?

  • It shouldn’t have toNameView doesn’t read count.
  • 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.


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 XX.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.


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.

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.

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 gate

and useReactraStoreFields wraps React’s selector-aware primitive:

// @reactra/store
return 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.


ApproachSubscription unitcount change re-renders NameView?Provider?
React Contextwhole context valueyesyes
Lifted useReducer + propsowner + childrenyes (unless memo’d)n/a
Reactra — inject store X { a } (field-selected)selected field listnono (registry)
Reactra — inject store X (namespace, X.a)whole store snapshotyesno (registry)
Zustandselector resultnono
Redux + useSelectorselector resultnoyes (inert for re-renders)
Jotaiatomnooptional

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 you a and b AND subscribes per-field — the read list is the selector. The compiler emits useReactraStoreFields("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 createSelector graph 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 XX.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:

  1. 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.
  2. 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).
  3. Memoize expensive subtrees with React.memo so a re-render of a parent doesn’t re-diff a heavy child whose props didn’t change.
  4. 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.
  • 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 XX.field): one notification per store/context; unrelated consumers re-render (cheaply, usually).
  • Fine (Zustand, Redux useSelector, Jotai, Reactra field-selected inject 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.