skip to content

In MobX, when should derived data be a computed value rather than an autorun or reaction, and when does a computed actually cache?

level: middleimportance: should knowfreq 36%

answer

  1. values versus side effects
  2. lazy and memoised
  3. only while someone observes
  4. data function, then effect
  5. never write state from a reaction

basics

~20 s

Use 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 s

A **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 lines
ts
import { 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

for a junior

Know the split: computed for values derived from state, autorun, reaction and when for side effects, and that reactions return a disposer.

for a middle

Explain lazy evaluation, caching only while observed, the equals comparer, and how reaction tracks only its data function.

for a senior

Spot designs that use reactions to maintain state, fix them with computeds, and account for suspension in tests and scripts that read computeds directly.

for a principal

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.