Choosing MobX Side‑Effect Primitives and Batching Mutations in React
A decision guide for picking MobX side‑effect primitives (autorun, reaction, when) and batching mutations with runInAction in React, including a comparison table, trade‑offs, and a concrete component example.
28 Sept 2025, 05:35 UTC

Decision: Which MobX Side‑Effect Primitive Fits Your Use Case?
\nWhen a React component needs to react to observable changes you have three built‑in primitives: autorun, reaction, and when. The choice depends on whether the effect must run immediately, only on data changes, or once when a condition becomes true. In addition, runInAction lets you batch multiple mutations so observers see a single atomic update.
Constraints to Consider
\n- \n
- Immediate execution –
autorunfires on definition;reactionandwhendo not. \n - Data‑driven vs. predicate‑driven –
reactionwatches a derived value;whenwatches a boolean predicate. \n - Lifecycle –
whendisposes itself after the first true evaluation; the others require manual disposal. \n - Batching need – If you update several observables in one event handler, wrap them in
runInActionto avoid intermediate notifications. \n
Compact Comparison
\n| Primitive | Runs on definition | Trigger | Auto‑dispose | Typical Use |
|---|---|---|---|---|
autorun | Yes | Any observable read inside the function | No | Logging, persistence, UI sync that must reflect current state instantly |
reaction | No | Change of the data function’s return value | No | Derived computations, API calls that should skip the initial value |
when | No | Predicate becomes true | Yes (after first true) | Waiting for a loading flag, one‑time initialization |
runInAction | N/A (batching helper) | N/A | N/A | Grouping multiple mutations into a single transaction |
Trade‑offs
\nAutorun
\nPros: simplest API, guarantees the effect runs whenever any accessed observable changes. Cons: the immediate first run can cause duplicate side‑effects (e.g., writing to localStorage on mount when the value is already correct).
\nReaction
\nPros: separates the what to watch (data function) from the what to do (effect), skipping the initial evaluation. Cons: you must ensure the data function is pure and returns a stable reference; otherwise the effect may fire unexpectedly.
\nWhen
\nPros: ideal for one‑shot await‑like patterns; auto‑disposes, reducing leak risk. Cons: if the predicate never becomes true the disposer stays alive—always keep a reference or clean up in a useEffect return.
runInAction
\nPros: guarantees atomic visibility of all mutations; reduces reaction overhead. Cons: only batches synchronous code; asynchronous continuations need their own runInAction calls.
Concrete Implementation in a React Component
\nAssume a MobX 6 store using makeAutoObservable and React 18 with mobx-react-lite.
// store.ts\nimport { makeAutoObservable, runInAction } from 'mobx';\n\nexport class TodoStore {\n todos: string[] = [];\n loading = false;\n filter = 'all';\n\n constructor() {\n makeAutoObservable(this);\n }\n\n async fetchTodos() {\n this.loading = true;\n const data = await fetch('/api/todos').then(r => r.json());\n runInAction(() => {\n this.todos = data;\n this.loading = false;\n });\n }\n\n addTodo(text: string) {\n runInAction(() => {\n this.todos.push(text);\n this.filter = 'all';\n });\n }\n}\n\nexport const store = new TodoStore();\n\n\nUsing the primitives
\n// TodoList.tsx\nimport { observer } from 'mobx-react-lite';\nimport { autorun, reaction, when } from 'mobx';\nimport { store } from './store';\nimport { useEffect } from 'react';\n\nconst TodoList = observer(() => {\n // 1. Persist filter changes immediately\n useEffect(() => {\n const dispose = autorun(() => {\n localStorage.setItem('filter', store.filter);\n });\n return dispose;\n }, []);\n\n // 2. Fetch suggestions only when filter changes (skip initial)\n useEffect(() => {\n const dispose = reaction(\n () => store.filter,\n (currentFilter) => {\n if (currentFilter !== 'all') {\n fetch(`/api/suggestions?filter=${currentFilter}`);\n }\n }\n );\n return dispose;\n }, []);\n\n // 3. Show a toast once loading finishes\n useEffect(() => {\n const dispose = when(\n () => !store.loading,\n () => alert('Todos loaded!')\n );\n return dispose; // safe even if predicate already true\n }, []);\n\n return (\n \n store.filter = e.target.value}\n >\n \n \n \n \n store.fetchTodos()} disabled={store.loading}>\n {store.loading ? 'Loading…' : 'Reload'}\n \n \n {store.todos.map((t, i) => - {t}
)}\n
\n \n );\n});\n\nexport default TodoList;\n\n\nValidation Steps (Run in Project Root)
\n- \n
- Install dependencies:
npm install mobx mobx-react-lite(requires Node ≥ 18, write permission tonode_modules). \n - Add a test file
side-effects.test.tsthat creates a store instance, attaches each primitive, mutates observables, and asserts call counts usingjest.fn()spies. \n - Run the test suite:
npm test -- --testPathPattern=side-effects.test.ts. Expected checks:\n- \n
autorunspy called on mount and after each relevant mutation. \n reactionspy called only after the data function’s return value changes, not on mount. \n whenspy called exactly once whenloadingflips tofalse; subsequent changes do not invoke it. \n - Two mutations wrapped in
runInActionproduce a single reaction notification; separate mutations produce two. \n
\n - Observe console output: the
autorunlog appears immediately, thereactionlog appears only after filter change, the toast appears once after load. \n
Risks: Running tests mutates global MobX state; ensure each test disposes its reactions in afterEach. The when disposer becomes inert after firing—calling it again is a no‑op but should not be relied upon for cleanup.
Limitations & Practical Checks
\n- \n
autoruncannot be delayed; if you need to skip the first run, switch toreactionwith a data function that returns the current value. \nreaction’s data function must be deterministic; returning a new object each time will trigger the effect on every evaluation. \nwhenonly works for predicates that eventually become true; for long‑lived conditions consider a regularreactionwith a guard. \nrunInActiondoes not batch acrossawaitboundaries—wrap each async continuation separately. \n- Verify behavior in development by enabling
mobx.setGlobalConfig({ enforceActions: 'always' })and confirming no “Side effect” warnings appear. \n
How to Confirm the Result in Production
\nAdd a lightweight telemetry hook that increments a counter each time a reaction fires. In the browser console, after a user interaction, the counter should match the expected number of batched updates (e.g., 1 for a batched addTodo, 2 for two separate filter changes). This runtime check validates that the chosen primitives and batching behave as designed without relying on test‑only mocks.
Summary
\nPick autorun for always‑on side‑effects, reaction when you need to skip the initial run and watch a derived value, and when for one‑time await‑style conditions. Wrap any multi‑property mutation in runInAction to keep observers in sync. The table, trade‑off notes, and the React example above give a decision framework you can apply directly to a MobX 6 / React 18 codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.