Comparison
Supergrain isn't the only fine-grained reactive library for React. This page puts Supergrain side-by-side with the alternatives — showing both the API you write and the internals under the hood. Signal-based libraries are called out specifically so you can compare them as a group.
Table of contents
Supergrain
State container libraries — React's built-in state or a store with manual, selector-based subscriptions. No automatic per-property tracking.
- useState — React built-in
- Zustand — plain object store + selectors
- Redux / RTK — actions + reducers + selectors
Signal-based libraries — automatic fine-grained reactivity driven by a reactive primitive (signal, observable, atom, or proxy-tracked property). Supergrain belongs in this group.
- MobX — observables + reactions +
observer()HOC - Preact Signals —
signal(value)containers - Jotai — atoms
- Valtio — proxy + snapshots
- TanStack Store — single atom + selectors (same alien-signals primitive as Supergrain)
Related (not React)
- Solid — not a React library, but the architecture Supergrain borrows from
Recap
Supergrain
// [#DOC_TEST_52](packages/doc-tests/tests/readme-core.test.ts)
interface State { count: number; user: { profile: { name: string } } }
const store = createReactive<State>({ count: 0, user: { profile: { name: 'John' } } })
// Mutate
store.count = 5
// Deep nested
store.user.profile.name = 'Bob'
// Fine-grained — only re-renders when count changes
const Counter = tracked(() => {
return <p>{store.count}</p>
})Internals.
Source refs: packages/kernel/src/read.ts, packages/kernel/src/react/tracked.ts, packages/kernel/src/batch.ts
- State shape. Every object in the tree is wrapped in its own JavaScript Proxy, created lazily via
wrap()on first access. Each property gets its own signal on first read. No explicit observables, no atom declarations — the reactive graph mirrors the object's shape. - Reactive primitive. Signal propagation uses alien-signals, the same primitive Vue Vapor is built on. Push-based updates, topological ordering, glitch-free
computedchains — no manual scheduling. - Fine-grained tracking.
tracked()wraps the component's render in an alien-signalseffect()scope. Proxy reads during render auto-subscribe that scope to exactly the signals they touched. When a signal fires, only the components that actually read it re-render — no selectors, no universal fan-out. - React bridge.
tracked()usesuseReducer+ alien-signalseffect()— notuseSyncExternalStore. The effect scope captures which signals the component read; when one fires, the reducer forces a re-render of just that component. - Mutation. In-place:
store.user.profile.name = "Bob"updates the affected property with fine-grained notifications rather than invalidating everything that depends on the store. Internally, writes may also bump the target object's version / structural signal. No spreading, no updater functions, no snapshot layer.batch()groups multiple mutations into a single notification cycle.
Footguns / Downsides
- You have to remember
tracked(). Fine-grained React updates depend on wrapping components intracked(); if you forget, you lose the per-component subscription behavior the library is built around. See the README quick start. - Only plain objects and arrays are reactive.
Map,Set, class instances,Date, and other built-ins pass through unchanged, so mutating them will not trigger re-renders. See the FAQ. batch()is sync-only. You can'tawaitinside a batch; the library explicitly throws because batching is implemented with a global depth counter. See the FAQ.- Fresh props can still defeat memoization.
tracked()wraps components inReact.memo, but passing fresh inline objects, arrays, or closures as props still causes prop-driven re-renders. See the FAQ.
useState
// [#DOC_TEST_53](packages/doc-tests/tests/readme-core.test.ts)
const [state, setState] = useState<State>({
count: 0,
user: { profile: { name: "John" } },
});
// Mutate
setState((prev) => ({ ...prev, count: 5 }));
// Deep nested
setState((prev) => ({
...prev,
user: { ...prev.user, profile: { ...prev.user.profile, name: "Bob" } },
}));
// Fine-grained — ❌ not possible. Re-renders on ANY state change.Internals.
Source refs: ReactFiberHooks.js
- State shape. State lives on the component's fiber — no store, no graph, just a pair returned from the hook.
- Reactive primitive. None. React compares old and new state by reference (
Object.is); if they differ, it schedules a re-render of that component and its subtree. - Fine-grained tracking. Not possible. Any change to the state object re-renders the whole component.
- React bridge. N/A —
useStateis built into React. Re-renders go through the normal fiber reconciliation, not through an external-store subscription. - Mutation. Immutable:
setState((prev) => ({ ...prev, count: 5 })). Every nested field you want to change requires spreading every layer above it.
Footguns / Downsides
- No fine-grained updates for object state. If one field changes, the whole component re-renders; React does not track property-level reads.
- Deep updates get verbose fast. React's own docs show that nested updates require copying every level above the change, and recommend flattening state or using Immer when this gets repetitive. See Updating Objects in State.
- Direct mutation fails silently. Mutating an existing object in state does not trigger a render, which can leave the UI stale until some unrelated update happens. See Updating Objects in State.
Zustand
// [#DOC_TEST_54](packages/doc-tests/tests/readme-core.test.ts)
const useStore = create<State>()((set) => ({
count: 0,
user: { profile: { name: 'John' } },
}))
// Mutate
useStore.setState({ count: 5 })
// Deep nested — manual spreading
useStore.setState(state => ({
user: { ...state.user, profile: { ...state.user.profile, name: 'Bob' } }
}))
// Fine-grained — requires selector
const Counter = () => {
const count = useStore(state => state.count)
return <p>{count}</p>
}Internals.
Source refs: README.md, src/vanilla.ts, src/traditional.ts
- State shape. A closure holding the state object and a
Setof listeners. No proxy, no reactive graph — state is a plain object. - Reactive primitive. None per-property. Every
setStatenotifies every subscriber; fine-grained behavior comes entirely from selector equality checks, not reactive tracking. - Fine-grained tracking. Selector-driven. The hook runs your selector on every change and bails out when the result matches by reference (
Object.is). The developer writes the selector and picks the right granularity. - React bridge. Each
useStore(selector)subscribes to the listener set; when notified, it re-runs the selector and re-renders only if the result changed. - Mutation. Immutable:
useStore.setState({ count: 5 })oruseStore.setState((prev) => ({ ... })).Object.assignshallow-merges into the current state. No batching — eachsetStatenotifies every subscriber.
Footguns / Downsides
- Easy to over-subscribe. Zustand's own README warns that reading the whole store with
const state = useBearStore()will update the component on every state change. See the README. - Selectors need discipline. If you build objects or arrays in selectors, you often need
useShallowor a custom equality function to avoid unnecessary re-renders. See the README. - Deep updates are still immutable update work. Zustand's docs explicitly say state must be updated immutably, and point to Immer for nested structures because that code gets tedious. See the README and README.
- Replacing state can blow away actions. The
setfunction can replace the whole store, and Zustand warns to be careful not to wipe out actions you rely on. See the README.
Redux / RTK
// [#DOC_TEST_55](packages/doc-tests/tests/readme-core.test.ts)
const slice = createSlice({
name: 'app',
initialState: { count: 0, user: { profile: { name: 'John' } } } as State,
reducers: {
setCount: (state, action) => { state.count = action.payload },
setName: (state, action) => { state.user.profile.name = action.payload },
},
})
// Mutate — typically modeled through named actions / reducers
dispatch(setCount(5))
// Deep nested — still routed through actions / reducers
dispatch(setName('Bob'))
// Fine-grained — requires useSelector
const Counter = () => {
const count = useSelector((state: RootState) => state.app.count)
return <p>{count}</p>
}Internals.
Source refs: createSlice.ts, createReducer.test.ts, useSelector.ts
- State shape. Immutable state behind a reducer. Updates that change state produce new references; no-op paths can return the existing state.
- Reactive primitive. None per-property. Every dispatched action fans out to every subscriber; selectors bail out on reference equality.
- Fine-grained tracking. Selector-driven via
useSelector. Re-runs on every dispatch; re-renders only when the returned value changes by reference. - React bridge.
react-reduxsubscribes eachuseSelectorto store dispatches and ties into React's re-render scheduling. - Mutation. Actions + reducers. RTK's
createSliceuses Immer under the hood, so "mutations" written inside reducers are compiled to immutable updates. You can also return immutable copies directly.
Footguns / Downsides
- Selectors are easy to get wrong. React Redux warns that returning a new object from
useSelectorforces a re-render by default, and that selecting the whole root state is almost always a mistake. See theuseSelectordocs. - Hooks have edge cases. React Redux still documents "stale props" and "zombie child" cases for hook-based selectors in some update orders. See the usage warnings.
- Immutable update code is still nasty without Immer. Redux's own immutable update guide says every nesting level must be copied and updated appropriately, and shows how verbose deep updates get. See Immutable Update Patterns.
- RTK's mutating reducer syntax is context-dependent. Redux explicitly warns that the concise
state.x = ystyle is only safe inside Immer-powered reducers such ascreateReducer/createSlice; outside that context it really mutates state. See Immutable Update Patterns.
MobX
// [#DOC_TEST_56](packages/doc-tests/tests/readme-core.test.ts)
class AppStore {
count = 0
user = { profile: { name: 'John' } }
constructor() { makeAutoObservable(this) }
}
const store = new AppStore()
// Mutate
store.count = 5
// Deep nested
store.user.profile.name = 'Bob'
// Fine-grained — requires observer + makeAutoObservable ceremony
const Counter = observer(() => {
return <p>{store.count}</p>
})Internals. Fine-grained like Supergrain, but observability is opt-in and the graph uses MobX's own reaction pattern rather than alien-signals.
Source refs: docs/observable-state.md, docs/react-integration.md, useObserver.ts
- State shape. Observable objects, arrays, maps, and fields, but observability is opt-in — you mark what's reactive via
observable()/makeAutoObservable/ decorators.observable()creates a separate observable object and, when proxies are enabled, returns it through a Proxy;makeAutoObservable(this)annotates an existing class instance. Supergrain's proxy wraps the whole tree automatically; nested objects are lazily proxied viawrap()on first access with no declarations. - Reactive primitive. Reaction-based observer pattern. Each observable maintains an
observers_set and propagates changes throughpropagateChanged(). Supergrain uses alien-signals — push-based, topologically ordered, glitch-free computed chains. - Fine-grained tracking.
observer()HOC runs the render inside aReactionthat captures observable reads and re-runs the component when any read observable changes. - React bridge.
observer()wraps components withuseSyncExternalStoreinternally. Supergrain'stracked()usesuseReducer+ alien-signalseffect()— nouseSyncExternalStoresnapshot. - Mutation. Direct mutation is allowed (
store.count = 5). Actions (runInAction,@action) provide batching and enforce mutation discipline.
Footguns / Downsides
- You have to wrap reading components. MobX's docs say the rule of thumb is to apply
observerto all components that read observable data; forgetting it is the most common reason things don't re-render. See React integration and Troubleshooting. - Tracking can break when reads happen in the wrong place. MobX warns to read observables inside
observercomponents, not before them, and not to pass observables into non-observerchildren without converting them first. See React integration. - Some React features are a worse fit for local observables. MobX recommends not reaching for local observable state too quickly because it can theoretically lock you out of some Suspense behavior. See You might not need locally observable state.
- Props and computed values can need manual syncing. MobX documents that component props are not themselves observable, so computed values derived from props may require explicit syncing into local observable state. See deriving computeds from props.
Preact Signals
import { signal } from "@preact/signals-react";
// With the Babel transform enabled (recommended). Without it,
// call `useSignals()` from `@preact/signals-react/runtime`.
const count = signal(0);
const user = signal({ profile: { name: "John" } });
// Mutate
count.value = 5;
// Deep nested — replace the whole object, or nest signals per field
user.value = { ...user.value, profile: { name: "Bob" } };
// Fine-grained — component subscribes to any signal it reads
const Counter = () => <p>{count.value}</p>;Internals.
Source refs: packages/react/README.md, packages/react/runtime/src/index.ts
- State shape. Individual
signal(value)containers. Each reactive unit is its own object; nested state requires nested signals or replacing the whole object on update. Supergrain's proxy returns bare property values (store.user.nameis a string, notsignal.value) and creates signals lazily under the hood. - Reactive primitive. Preact's own signal runtime. Reads go through
signal.value, a property getter that registers the current effect as a subscriber. - Fine-grained tracking. The component's render scope subscribes to every signal it
.value-accesses; when one fires, the component re-renders. If you pass a signal directly into JSX instead of reading.value, the React adapter can update the bound text node directly. - React bridge.
@preact/signals-reacttracks a 32-bit version counter per subscribed scope and notifies React throughuseSyncExternalStorewhen it changes. (A Babel transform variant auto-injects the subscription.) - Mutation. Direct write to
.value:count.value = 5. Deep nested changes require replacing the whole object or nesting signals per field.
Footguns / Downsides
- You pay the
.valuetax. Signals are explicit containers; reads and writes go through.value, which is more ceremony than plain object property access. See the Signals guide. - Object and array state often means replacement, not deep mutation tracking. The official examples update list/object-like values by assigning a new value to the signal, not by tracking nested fields automatically. See the todo example.
- Computed values are lazy. Preact documents that computed signals do not recompute until something reads them, which is great for perf but can surprise people expecting eager propagation. See Reacting to signals outside of components.
- Effects require cleanup discipline. The docs explicitly warn to clean up effects or the app will consume more memory than needed. See Reacting to signals outside of components.
Jotai
import { atom, useAtomValue, useSetAtom } from "jotai";
const countAtom = atom(0);
const userAtom = atom({ profile: { name: "John" } });
// Mutate (inside a component or event handler)
const setCount = useSetAtom(countAtom);
setCount(5);
// Deep nested — manual spreading, per atom
const setUser = useSetAtom(userAtom);
setUser((prev) => ({ ...prev, profile: { name: "Bob" } }));
// Fine-grained — one subscription per atom
const Counter = () => <p>{useAtomValue(countAtom)}</p>;Internals.
Source refs: README.md, src/react/useAtomValue.ts
- State shape. Decomposed into atoms. Each atom is a separate reactive unit. No shared object tree — you wire atoms together with derived atoms. Supergrain is the inverse shape: one proxy, signals created lazily per property, memory that scales with object complexity rather than atom count.
- Reactive primitive. Atom graph. Derived atoms depend on primitive atoms; a context-scoped store tracks dependencies between them.
- Fine-grained tracking. One subscription per atom.
useAtomValue(countAtom)subscribes only to that atom; only components using it re-render when it changes. - React bridge.
useAtomValueis backed byuseReducer+useEffect; each hook subscribes to a single atom viastore.sub(atom, callback). - Mutation. Immutable updaters per atom:
setUser((prev) => ({ ...prev, name: "Bob" })). Nested state either lives in one coarse atom (lose fine-grained updates) or is decomposed into per-field atoms (more memory, more wiring).
Footguns / Downsides
- Atom identity matters. Jotai's docs warn that creating atoms inline without memoization can cause infinite loops because referential equality is part of the model. See
useAtom. - You can still get extra React renders. Jotai explicitly notes that an extra render without a commit is expected because React may invoke components more than once. See
useAtom. - Large objects push you toward more utilities and decomposition. Jotai's "Large objects" recipe introduces
focusAtom,splitAtom, andselectAtomspecifically to avoid listening to all changes on one big object atom. See Large objects. - Deep immutable updates do not disappear. If you keep nested state inside one object atom, updates still go through immutable replacement/updater functions.
Valtio
import { proxy, useSnapshot } from "valtio";
const state = proxy({ count: 0, user: { profile: { name: "John" } } });
// Mutate
state.count = 5;
// Deep nested
state.user.profile.name = "Bob";
// Fine-grained — useSnapshot tracks which properties the render reads
const Counter = () => {
const snap = useSnapshot(state);
return <p>{snap.count}</p>;
};Internals. Closest to Supergrain on the API axis — also a proxy, also allows direct mutation with no observable declarations. Where it diverges is the React bridge: an immutable snapshot layer wrapped in a render-tracking proxy.
Source refs: README.md, src/react.ts, tests/basic.test.tsx
- State shape. Proxy wraps the whole object tree (like Supergrain). Nested objects are auto-proxied on mutation.
- Reactive primitive. Property-access tracking via the
proxy-comparelibrary.useSnapshotcreates an immutable snapshot on each update and wraps it in a tracking proxy to detect which properties were read during render. - Fine-grained tracking. The tracking proxy records every property accessed during render; the component re-renders when any of those properties changes in a future snapshot.
- React bridge.
useSnapshotsubscribes viauseSyncExternalStore. Supergrain skips the snapshot layer entirely — reads go through the live proxy, tracked by alien-signals. - Mutation. Direct mutation allowed:
state.count = 5,state.user.profile.name = "Bob". The React path rebuilds snapshots and re-runs property-access comparison on updates. Supergrain's writes are in-place andbatch()groups them into a single notification cycle.
Footguns / Downsides
- Broad reads broaden subscriptions. Valtio's gotchas page says that if you read
snap.objinstead ofsnap.obj.count, the component will re-render on any change insideobj. See Some gotchas. - Manual rerender control is real complexity, not just theory. Valtio has a dedicated guide for manually avoiding rerenders with
subscribe, local state, oruseSyncExternalStore, which is a sign that the default mental model is easy to over-subscribe with. See How to avoid rerenders manually. statevssnapis a real footgun. The docs explicitly saysnapshould be used in render andstatein callbacks/effects; mixing them up is a common source of confusion. See Some gotchas.- Controlled inputs can need special handling. Valtio documents that controlled inputs may lose caret position unless you opt into
{ sync: true }. See Some gotchas.
TanStack Store
import { Store, useSelector } from "@tanstack/react-store";
const store = new Store({ count: 0, user: { profile: { name: "John" } } });
// Mutate — immutable updater, always replaces the whole value
store.setState((prev) => ({ ...prev, count: 5 }));
// Deep nested — manual spreading through the updater
store.setState((prev) => ({
...prev,
user: { ...prev.user, profile: { ...prev.user.profile, name: "Bob" } },
}));
// Fine-grained — via selector, compared with === (or custom compare)
const Counter = () => {
const count = useSelector(store, (s) => s.count);
return <p>{count}</p>;
};Internals. Closest to Supergrain on the reactive-primitive axis — both libraries sit on the same reactive graph algorithm. TanStack's packages/store/src/alien.ts opens with:
/* eslint-disable */
// Adapted from Alien Signals
// https://github.com/stackblitz/alien-signals/TanStack forked and vendored alien-signals; Supergrain imports it from npm. Below that shared graph, the two libraries look very different:
Source refs: packages/store/src/alien.ts, packages/store/src/store.ts, packages/store/src/atom.ts, packages/react-store/src/useSelector.ts
- State shape.
Store<T>wraps a singleAtom<T>holding the whole state object. Supergrain wraps the whole tree in a proxy and creates a signal per property lazily, so reactive granularity is per-field rather than per-store. - Reactive primitive. Same alien-signals graph —
link(dep, sub, version)dependency tracking,ReactiveFlagsbitfield,propagate/checkDirty/shallowPropagatepipeline. - Fine-grained tracking. Selector-driven. Every
setStatenotifies every subscriber;useSelectorruns its selector and uses thecompareoption (default===) to bail out of the re-render. Supergrain'stracked()wraps render in an alien-signalseffect()scope, so only signals the component actually read trigger a re-render — no universal fan-out. - React bridge.
useSyncExternalStoreWithSelectorfromuse-sync-external-store/shim/with-selector. Supergrain usesuseReducer+ alien-signalseffect()— it benchmarkeduseSyncExternalStorefor per-item subscriptions during its optimization pass and rejected it at 74% slower for row-level work. TanStack's single-atom + selector model sidesteps that cost because there's only oneuseSyncExternalStoresubscription peruseSelectorcall, not per item. - Mutation. Immutable updater:
store.setState((prev) => ({ ...prev, count: 5 })). Deep nested changes require spreading every layer. Supergrain's writes are in-place:store.user.profile.name = "Bob"fires exactly one signal. - Derived / async. First-class computed atoms (
createAtom((prev) => fn(prev))) and async atoms (createAsyncAtomreturns a discriminated-union state atom —{ status: 'pending' },{ status: 'done', data }, or{ status: 'error', error }). Supergrain hasuseComputedfor derived values; async is user-land (drive state into the store from an effect).
Full research notes in notes/comparisons/tanstack-store.md.
Footguns / Downsides
- Granularity still depends on writing selectors correctly. TanStack Store's primary React API is
useSelector; if you omit the selector, the docs say you subscribe to the whole value. SeeuseSelector. - Nested writes are immutable update code. Even the quick-start examples update state with object spreads inside
setState, so deep changes still mean copying through the path you changed. See the React quick start. - No automatic proxy-style property tracking. You get fine-grained re-renders through manual selector boundaries, not by reading from a live reactive object tree.
Solid
Solid isn't a React library, but it's the architecture Supergrain borrows from: proxy-wrapped stores where each property is backed by a signal, with fine-grained DOM updates driven by the compiler. Solid's compiler creates a direct signal→DOM mapping, which eliminates virtual-DOM diffing entirely.
Source refs: README.md, packages/solid/store/src/store.ts
Supergrain can't rely on compilation — React owns the render cycle — so tracked() exists to bridge signals into React's top-down reconciliation. Each tracked component runs inside its own signal-tracking scope; when a signal it read fires, only that component re-renders. This is the per-component signal scoping that makes fine-grained reactivity possible in React without a Babel transform or useSyncExternalStore snapshot.
Footguns / Downsides
- It is not a React library. The architectural lessons transfer, but the programming model assumes Solid's renderer and store/runtime semantics, not React's component update model.
- Tracking only exists inside tracking scopes. Solid's store docs explicitly show that reading store values outside a tracked scope does not establish dependencies, which can surprise React users expecting subscription semantics everywhere. See Stores.
- Writes go through
setStore, not plain assignment. Solid's default store API separates reads from writes and uses setter/path syntax; that is a different ergonomics tradeoff from direct mutable-looking APIs. SeecreateStoreand Stores.
Summary
Signal-based React libraries cluster around a few internal patterns:
| Library | Reactive unit | React bridge | Nested state |
|---|---|---|---|
| MobX | Explicit observables | observer() + useSyncExternalStore | makeAutoObservable or observable |
| Preact Signals | signal(value) containers | Version tracking + useSyncExternalStore | Nested signals or replace-on-update |
| Jotai | Atoms | useAtomValue (useReducer + useEffect) | Atomic decomposition |
| Valtio | Proxy + snapshots | useSnapshot + useSyncExternalStore | Auto-proxied on mutation |
| TanStack Store | Single atom (forked alien-signals) | useSelector + useSyncExternalStoreWithSelector | Spread through setState |
| Supergrain | Proxy + alien-signals (per-property) | tracked() (useReducer + effect()) | Auto-proxied via wrap() |
Supergrain's specific combination:
- alien-signals for propagation (push-based, topological, glitch-free computed chains) — shared with TanStack Store, which forks the same algorithm.
- Lazy proxy wrapping of the whole tree — no explicit observables, no atom declarations, no snapshot layer. Each property becomes its own signal on first access.
tracked()for per-component subscription scoping —useReducer+ alien-signalseffect(), notuseSyncExternalStore. Only signals a component actually reads during render trigger its re-renders.
The result is plain store.user.name = "x" reads and writes, automatic fine-grained re-renders, and in-place updates that don't pay a snapshot or immutable-spread cost.