MobX Computed Values: The Engineering Case for Letting Derived State Derive Itself
MobX computed values are memoized and lazy, so derived state can live in the reactive graph instead of being manually synced. A cart store example shows the pattern, plus the debugging and deep-observability trade-offs.
16 Oct 2025, 23:59 UTC

Every non-trivial UI eventually accumulates a second, shadow state: totals, filtered lists, validation flags, "is anything selected" booleans. The classic failure mode is keeping that derived data in state too, then trying to update it in every place the source data changes. One missed call site and the cart shows a stale total. MobX's answer is a deliberate engineering decision: never store derived state at all — declare it as a computed and let the dependency graph keep it consistent.
This post targets MobX 6 with mobx-react-lite. The annotation API changed significantly from MobX 4/5 (decorators were removed from the core API), so check your package.json before copying the examples.
Transparent reactivity, in one paragraph
MobX tracks which observables a derivation reads while it runs. When any of those observables later change, only the derivations that actually read them are scheduled to re-run. There is no subscription wiring, no selector memoization, no dependency array to keep honest. The graph is built automatically at runtime by observing reads, which is why it's called transparent reactivity.
Computed values sit inside that graph as first-class nodes. Two properties make them cheap to use liberally:
- Memoized: a computed re-executes its function only when one of its observed dependencies changes. Reads in between return the cached value.
- Lazy: if nothing is observing a computed, it isn't kept up to date at all. Suspended computeds cost nothing until something reads them again.
The practical consequence: you can model every derived value as a computed without the performance anxiety that comes with recompute-on-every-render approaches.
A worked example: cart store
Run this in a React app with mobx and mobx-react-lite installed (version 6+). No special permissions or configuration needed beyond a standard bundler setup.
import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";
class CartStore {
items = []; // { id, name, price }
constructor() {
// fields -> observable, getters -> computed, methods -> action
makeAutoObservable(this);
}
get total() {
// Pure: reads observables, returns a value, no side effects.
return this.items.reduce((sum, i) => sum + i.price, 0);
}
get isEmpty() {
return this.items.length === 0;
}
addItem(item) {
this.items.push(item);
}
}
const cart = new CartStore();
const CartSummary = observer(() => (
<p>{cart.isEmpty ? "Cart is empty" : `Total: $${cart.total}`}</p>
));Three things are doing real work here. makeAutoObservable(this) infers annotations from shape: plain fields become observable, getters become computed, methods become actions — one call, no separate makeObservable annotation block to drift out of sync with the class. The total getter recalculates only when items changes, and only while something observes it. The observer wrapper makes the component itself a derivation: it re-renders only when data it actually dereferenced during render changes. A sibling component that never touches cart will not re-render when an item is added.
To verify the memoization claim yourself, temporarily add a console.log("recomputing total") inside the getter (or wrap the component body in MobX's trace()). You should see the log fire on addItem but not on unrelated renders. In React DevTools profiling, unrelated components should show no commits on cart changes.
Actions keep mutations traceable
MobX 6 enables enforceActions: "observed" by default: if you modify an observed observable outside an action, you get a warning (or error, in stricter modes). This is a feature, not friction. Actions batch notifications — observers run once after the action completes, not once per assignment — and they give every mutation a named origin, which matters when you're later staring at a trace() log wondering what changed state.
With makeAutoObservable, methods are actions automatically, so the discipline costs nothing. You can confirm the guard works by mutating cart.items.push(...) directly from an event handler in a scratch project and watching the console warning appear.
The trade-offs worth knowing before you commit
Debugging flow is different, not free. Fine-grained reactivity hides the update path compared with reducer-style state management. In Redux you get one action log; in MobX you get a graph that propagates silently. The compensating tools are trace(), spy(), and naming your actions well — but expect a learning curve when a computed updates "for no reason" (the reason is always a read you forgot about).
Deep observability is the default. Objects, arrays, and maps assigned to observable fields are converted deeply. For large or frequently replaced payloads (say, a big API response you swap wholesale), that conversion is wasted work. Annotate the field as observable.ref or observable.shallow to opt out — but note that makeAutoObservable infers deep observability, so you'll need explicit makeObservable annotations or makeAutoObservable(this, { data: observable.ref }) overrides for those fields.
Keep computeds pure. A computed that fetches, mutates, or logs-and-acts will eventually fire at surprising times or not at all, because laziness means it only runs when observed. Side effects belong in autorun or reaction, where you control the trigger explicitly.
Where this leaves you
The decision MobX asks you to make is to trust the graph: declare sources as observables, declare everything derivable as computed, mutate only in actions, and wrap rendering components in observer. In exchange, the entire category of "stale derived state" bugs disappears, and re-render scope narrows to exactly what each component reads. Start with one store, add a log line to a computed getter, and watch when it fires — that five-minute experiment tells you more about whether the model fits your team than any feature list will.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.