skip to content

A MobX autorun in a todo app re-runs more often than expected and keeps running after its screen closes; how do you find what it tracks, and how do you stop it?

level: seniorimportance: should knowfreq 30%

answer

  1. everything read is a dependency
  2. serialising reads every field
  3. a utility that explains re-runs
  4. the return value of autorun
  5. effect cleanup in React

basics

~20 s

An autorun depends on everything it read in its last run, so serialising the whole list makes any field change re-run it. trace() or getDependencyTree shows those dependencies, reaction narrows them, and calling the returned disposer on unmount stops it.

solid answer

~50 s

MobX subscribes an `autorun` to **every** observable and computed it read during its last synchronous run. If it logs `JSON.stringify(store.todos)`, serialisation reads every field of every todo, so editing any title re-runs it. To see this, call `reaction.trace()` on the reaction argument (or `trace()` inside it; `trace(true)` opens the debugger at the triggering change), inspect `getDependencyTree` for a reaction or computed, or log everything with `spy`. Narrow it with `reaction(data, effect)`, whose effect depends only on what the data function returns, or read a computed that changes less often. As for running forever: `autorun`, `reaction` and `when` all **return a disposer**, and a reaction is collected only when everything it observes is; one created by a screen against a long-lived store keeps running until the disposer is called. In React, return it from `useEffect` so unmount disposes it.

code

tsx · 20 lines
tsx
import { useEffect } from 'react'
import { reaction, trace } from 'mobx'
import { observer } from 'mobx-react-lite'
import type { TodoStore } from './todo-store'

export const TodoScreen = observer(({ store }: { store: TodoStore }) => {
  useEffect(
    () =>
      // tracks only the length; the returned disposer is the effect cleanup
      reaction(
        () => store.todos.length,
        (count, prev) => console.log(`todos: ${prev} -> ${count}`),
        { name: 'log todo count' },
      ),
    [store],
  )

  trace() // logs why this observer re-rendered
  return <p>{store.remaining} left</p>
})

go deeper

for a junior

Know that autorun, reaction and when return a disposer that must be called when the effect is no longer needed.

for a middle

Explain that a reaction depends on everything read in its last run, and how reaction's data function narrows that compared with autorun.

for a senior

Debug over-subscription with trace, getDependencyTree and getObserverTree, and tie every hand-made reaction to a lifecycle through its disposer or a signal.

for a principal

Accept implicit subscriptions knowingly: require named reactions, disposal in review, and development-time configure warnings so the lost visibility is restored by tooling.

## Why MobX reactions are hard to see MobX's **transparent reactivity** means nobody writes subscriptions. A reaction — an `autorun`, a `reaction`, or the render of an `observer` component — subscribes to whatever it happens to read. That is concise, but it makes two classes of bug possible: - **Over-subscription.** A reaction reads more than it needs and re-runs on unrelated changes. - **Leaked reactions.** A reaction keeps running after the thing that created it is gone. The todo scenario has both: a screen sets up `autorun(() => console.log(JSON.stringify(store.todos)))` to log changes, and after navigating away, logs keep appearing on every edit. ## Finding out what a reaction tracks An `autorun` depends on everything it read in its **last** run: observable fields, array items and lengths, and computeds (with their own dependencies behind them). `JSON.stringify` walks the whole array and reads every property of every todo, so each title, `done` flag and the array itself become dependencies. MobX ships tools to confirm this: | Tool | Where you call it | What it shows | |---|---|---| | `trace()` / `reaction.trace()` | inside a reaction, computed or observer render | logs why that derivation re-ran; `trace(true)` enters the debugger | | `trace(store, 'remaining')` | anywhere, naming a computed | the same, for that computed | | `getDependencyTree(thing, prop?)` | on a reaction or computed | every observable it currently depends on | | `getObserverTree(thing, prop?)` | on an observable | every reaction and computed observing it | | `spy(listener)` | once, globally | every action, change and reaction run | `trace(true)` is the fastest route to "who changed what": it pauses in the debugger when the triggering mutation is still a few stack frames up. `getObserverTree(store, 'todos')` answers the reverse question, which reactions a noisy observable wakes. ## Narrowing the dependencies 1. **Use `reaction` instead of `autorun`.** `reaction(() => store.todos.length, (n) => log(n))` tracks only what the data function reads, so the effect runs only when the length changes. 2. **Depend on a computed.** A computed such as `store.remaining` re-runs its readers only when its result changes by its comparer, filtering out edits that do not matter. 3. **Use comparers and options.** `equals: comparer.structural` on a reaction ignores structurally equal results; `delay` throttles bursts. 4. **Avoid reading in the effect what should be tracked in the data function**, and the reverse. ## Stopping reactions: disposal `autorun`, `reaction` and `when` all return a **disposer** function. Calling it unsubscribes the reaction from everything it observed. The MobX docs note that a reaction is garbage-collected only when all the objects it observes are, so a reaction created by a short-lived screen against a long-lived store lives as long as the store. Ways to dispose: - Call the disposer, for example from a store's own `dispose()` method. - In React, return it from an effect: `useEffect(() => autorun(() => { ... }), [store])`. The disposer is the effect's cleanup, so unmount stops it. - Pass `signal` (an `AbortSignal`) in the options and abort it. - Call `reaction.dispose()` from inside the effect, using the reaction argument. - Where supported, the disposer also implements `[Symbol.dispose]` for `using` and disposable stacks. `observer` components need none of this: their reaction is disposed when the component unmounts. `when` without an effect returns a promise that has a `cancel()` method. ## Reading a trace in practice Put `reaction.trace()` as the first line of the noisy autorun and edit one todo's title. The console names the observable whose change caused the re-run, for example a specific todo's `title`, which confirms that serialisation made titles dependencies. Switch the autorun to a `reaction` on `store.todos.length`, repeat the edit, and the effect stays silent; only adding or removing a todo triggers it now. ## Guard rails for a team - `configure({ reactionRequiresObservable: true })` warns when a reaction reads no observables, catching reactions that do nothing. - `configure({ observableRequiresReaction: true })` warns about observable reads outside any reactive context. - Give reactions a `name` option so `spy` output and developer tools are readable. - Review every `autorun` for a matching disposer, just as you would an event listener. ## The trade-off in one line MobX removes subscription code, and with it the place you would normally look to see what depends on what; `trace`, the dependency trees and `spy` put that visibility back.

  • Does a MobX observer component need its reaction disposed manually?
    No. `observer` from `mobx-react-lite` creates and owns the reaction behind the component's render and disposes it when the component unmounts. Manual disposal is needed for reactions you create yourself with `autorun`, `reaction` or `when`, for example inside a `useEffect` or a store constructor.
  • How do you find out which reactions wake up when store.todos changes?
    Call `getObserverTree(store, 'todos')`, which returns every reaction and computed currently observing that property. Giving reactions a `name` option makes the output readable. For a timeline across the whole app, register a `spy` listener or use the MobX developer tools, which are built on it.

saying these in an interview costs you the question

  • An autorun only tracks the observables named in its first line.
  • An autorun created in a component stops by itself when that component unmounts.
  • Reactions are garbage-collected as soon as nothing references the disposer.
  • spy shows why one specific reaction re-ran, so trace is redundant.
  • Every observer component must be disposed by hand in a cleanup effect.