In MobX, when should derived data be a computed value rather than an autorun or reaction, and when does a computed actually cache?
answer
- values versus side effects
- lazy and memoised
- only while someone observes
- data function, then effect
- never write state from a reaction
basics
~20 sUse computed for any value derived from state and reactions only for side effects such as saving or logging. A computed is lazy and caches its result only while something observes it; read outside a reaction, it recomputes on every access.
solid answer
~50 sA **computed** is a derived value: a getter annotated `computed` (or inferred by `makeAutoObservable`) that re-evaluates only when an observable it read changes, and notifies observers only if the new result differs by its `equals` comparer. It is **lazy** and, importantly, **caches only while observed**: if no `observer`, `autorun` or other computed depends on it, MobX suspends it and recomputes on every access, like a plain getter; `keepAlive` changes that, and `computedRequiresReaction` warns about such reads. **Reactions** — `autorun`, `reaction` and `when` — exist for **side effects**: writing to `localStorage`, logging, calling an API. `autorun` runs immediately and whenever anything it read changes; `reaction(data, effect)` runs the effect only when the data function's result changes; `when(predicate, effect)` runs once. The MobX docs' rule: if a reaction would write another observable, that value should be a computed instead.
code
ts · 25 linesimport { makeAutoObservable, reaction } from 'mobx'
type Todo = { id: string; title: string; done: boolean }
export class TodoStore {
todos: Todo[] = []
constructor() {
makeAutoObservable(this)
}
// value: computed, cached while observed
get remaining() {
return this.todos.filter((t) => !t.done).length
}
}
export function persistTodos(store: TodoStore) {
// side effect: reaction tracks only the data function
return reaction(
() => JSON.stringify(store.todos),
(json) => localStorage.setItem('todos', json),
{ delay: 300 },
) // returns the disposer
}go deeper
Know the split: computed for values derived from state, autorun, reaction and when for side effects, and that reactions return a disposer.
Explain lazy evaluation, caching only while observed, the equals comparer, and how reaction tracks only its data function.
Spot designs that use reactions to maintain state, fix them with computeds, and account for suspension in tests and scripts that read computeds directly.
Keep reactions rare and owned: agree where side effects live, how they are disposed, and when an explicit call from an action is clearer than a reaction.
## Two kinds of derivation MobX distinguishes **values** that follow from state and **effects** that should happen because state changed. - A **computed value** answers "what is true now?" — the number of remaining todos, the filtered list, whether the form is valid. - A **reaction** answers "what should happen when this changes?" — persist todos, send an analytics event, focus an input. Mixing them up is the most common MobX design mistake: using `autorun` to keep a `remaining` field up to date, instead of declaring `remaining` as a computed. ## Computed values in detail In a todo store, `get remaining() { return this.todos.filter((t) => !t.done).length }` becomes computed through `makeAutoObservable` or an explicit `computed` annotation. Its behaviour: 1. **Lazy.** It runs only when read. 2. **Tracked.** While it runs, MobX records which observables it reads (`todos`, each `done`). 3. **Memoised while observed.** If an `observer` component or a reaction depends on it, MobX keeps the result and recomputes only after one of the recorded observables changes. 4. **Change-filtered.** After recomputing, it compares the new result with the old one using its `equals` option (`comparer.default` by default, which is identity with `NaN` treated as equal). If equal, observers are not re-run. `computed.struct` or `comparer.structural` compare structurally instead. The part that surprises people is the **suspension** rule. A computed that nothing observes is suspended: reading `store.remaining` from a plain `setInterval` or a unit test recomputes it every time, exactly like an ordinary getter. MobX does this so that unused computeds do not stay subscribed and leak. You can override it with `keepAlive: true` on the annotation, or keep it observed with a disposable `autorun`, and you can make unobserved reads visible with `computedRequiresReaction` (globally via `configure`, or per computed with `requiresReaction`), which in MobX 6.16 logs a console warning and still recomputes. Computed rules from the docs: no side effects, do not update other observables, and do not depend on non-observable values. Methods that take arguments cannot be `computed`; their reads are still tracked when called from a reaction, but their results are not memoised. ## The three reaction functions | API | Runs when | Typical use | |---|---|---| | `autorun(effect)` | once immediately, then whenever anything it read changes | logging, syncing a whole derived state somewhere | | `reaction(data, effect)` | when `data()`'s result changes; not initially unless `fireImmediately` | saving todos when their serialised form changes | | `when(predicate, effect?)` | once, the first time the predicate becomes true | one-off setup; without `effect` it returns a promise | `reaction` is the precise tool: only reads in the **data function** are tracked, and the effect receives `(value, previousValue, reaction)`. Options such as `delay` throttle the effect and `equals` controls what counts as a change. All three return a **disposer**; a reaction that is never disposed keeps its observables and closure alive. ## Choosing, with the todo store - `remaining`, `visibleTodos`, `allDone` → **computed**. They are values; any component can read them and they update themselves. - Persisting todos to storage whenever they change → **reaction**, with `() => JSON.stringify(store.todos)` as the data function and the write in the effect. - Showing a toast once all todos are done → `when(() => store.allDone, showToast)`. - Posting to the server when the user clicks Save → **neither**; call it from the action or event handler. The docs advise reactions only when there is no direct relation between cause and effect. ## Common mistakes - An `autorun` that assigns `this.remaining = ...`: extra state, an extra update cycle, and ordering questions MobX does not answer, since reaction order is not guaranteed. - Benchmarking a computed from a script with no observer and concluding "computed does not cache". - Reading an observable in the reaction's **effect** but not its **data** function, then wondering why changes to it do not trigger the effect. - Forgetting to dispose a reaction created in a component or a short-lived object.
- Why does console.log(store.remaining) in a MobX unit test run the getter every time?Nothing observes `remaining` in the test, so MobX suspends the computed and evaluates it on each access, like a plain getter. It caches only while an observer, reaction or another observed computed depends on it. Wrap the reads in an `autorun`, use `keepAlive`, or accept the recomputation in tests.
- In a MobX reaction, why might a change to store.filter not trigger the effect even though the effect reads it?`reaction` tracks only observables read by its first, data function. Reads inside the effect are not tracked. If the effect should re-run when `filter` changes, return it (or something derived from it) from the data function, for example `() => [store.filter, store.todos.length]`, with a comparer such as `comparer.structural` for the array.
saying these in an interview costs you the question
- A MobX computed caches its result even when nothing observes it.
- autorun is the right tool for keeping a derived field in sync.
- reaction tracks every observable read in both its data and effect functions.
- reaction's effect runs once immediately, just like autorun.
- Computed getters may update other observables as long as they return a value.