skip to content

In MobX 6, why does assigning observable state after an await inside a store method trigger a strict-mode warning, and how do you fix it?

level: middleimportance: must knowfreq 42%

answer

  1. an action ends at the first await
  2. the default strictness level
  3. a warning, not a failure
  4. wrap the continuation
  5. generators instead of await

basics

~20 s

An action only covers the synchronous part of a function; code after await runs later, outside it. With the default enforceActions: "observed", changing observed state there logs a warning. Wrap the post-await writes in runInAction, or write the method as a flow generator.

solid answer

~50 s

In MobX an `action` wraps a function call in a **transaction**: writes are batched, reactions run once when the outermost action ends, and reads inside are untracked. An `async` method annotated as `action` (or inferred as `autoAction` by `makeAutoObservable`) is only inside that transaction until its first `await`; the continuation runs later as a separate microtask, **outside any action**. MobX 6's default strictness, `enforceActions: "observed"`, requires that state observed by something be changed inside actions, so assigning `this.todos = data` there logs, in development, "Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed". The write still happens; the warning flags lost batching and unclear update points. Fix it by wrapping each post-`await` block in `runInAction(() => { ... })`, by passing `action`-wrapped callbacks to `.then`, or by writing the method as a `flow` generator that uses `yield` instead of `await`.

code

ts · 40 lines
ts
import { flow, makeAutoObservable, runInAction } from 'mobx'

type Todo = { id: string; title: string; done: boolean }
declare const api: { getTodos(): Promise<Todo[]> }

export class TodoStore {
  todos: Todo[] = []
  state: 'idle' | 'pending' | 'done' | 'error' = 'idle'

  constructor() {
    makeAutoObservable(this, { loadWithFlow: flow })
  }

  // async/await: wrap every block of writes after an await
  async load() {
    this.state = 'pending'
    try {
      const data = await api.getTodos()
      runInAction(() => {
        this.todos = data
        this.state = 'done'
      })
    } catch {
      runInAction(() => {
        this.state = 'error'
      })
    }
  }

  // flow: yield instead of await, no wrapping needed
  *loadWithFlow() {
    this.state = 'pending'
    try {
      this.todos = (yield api.getTodos()) as Todo[]
      this.state = 'done'
    } catch {
      this.state = 'error'
    }
  }
}

go deeper

for a junior

Know that state changes belong in actions, and that code after await needs runInAction or a flow to count as one.

for a middle

Explain why an action ends at the first await, what enforceActions "observed" checks, and what batching is lost when writes run outside an action.

for a senior

Choose between runInAction, action-wrapped callbacks and flow for real async processes, including cancellation and error states, without disabling strict mode.

for a principal

Set a team convention for async store methods so update points stay explicit and reviewable, and keep strict mode on as a guard across the codebase.

## What an action guarantees MobX asks you to mark code that **modifies** state as an action, via the `action` annotation, `action(fn)`, `runInAction(fn)` or inference by `makeAutoObservable`. An action does three things: - **Batching.** It runs as a transaction; no reaction or `observer` re-render runs until the outermost action finishes, so intermediate states are never visible. - **Untracked reads.** Observables read inside an action do not become dependencies of whatever called it. - **A marked update point.** Strict mode can tell legitimate writes from accidental ones. ## Why `await` breaks out of it An action wraps a **function call**. An `async` function returns at its first `await`, and everything after that line runs later, in a separate microtask, when the awaited promise settles. By then the action has already ended: ```ts async fetchTodos() { this.state = 'pending' // inside the action const data = await api.getTodos() // the action ends here this.todos = data // outside any action this.state = 'done' // outside any action } ``` With `makeAutoObservable`, `fetchTodos` is inferred as `autoAction`, and the same rule applies: only its synchronous part is wrapped. ## What strict mode does about it MobX's `enforceActions` setting, configured with `configure`, has three values: | Value | Meaning | |---|---| | `"observed"` (default) | state that is observed somewhere must be changed inside an action | | `"always"` | all state changes must be inside actions, including during creation | | `"never"` | state can be changed from anywhere | Under the default, the two writes after `await` in `fetchTodos` produce a development-only `console.warn` when something, such as an `observer` list, observes `todos` or `state`. The assignment still takes effect; MobX is telling you that the writes were not batched (reactions may run once per assignment and see the in-between state) and that the update point is unmarked. In a unit test where nothing observes the store, the same code logs nothing, which is why the warning often appears only in the running app. ## Three fixes 1. **`runInAction` around each continuation.** The most direct fix: `runInAction(() => { this.todos = data; this.state = 'done' })`. Every block of writes after an `await` gets its own wrapper. 2. **Action-wrapped callbacks.** With promise chains, pass `action('fetchSuccess', (data) => { ... })` to `.then`. Naming the action helps in the MobX developer tools. 3. **`flow`.** Write the method as a generator and `yield` promises instead of awaiting them. MobX resumes the generator inside an action after each step, so no wrapping is needed. `makeAutoObservable` infers generator methods as `flow` (annotate them explicitly if your transpiler hides generators), and calling a flow returns a promise with a `cancel()` method that stops the generator while still running its `finally` blocks. ## Choosing between them - `runInAction` keeps plain `async`/`await` and is the most readable for one or two continuations. - `flow` scales better for long multi-step processes and gives cancellation for free; its TypeScript return type needs care (`yield` results are typed loosely unless you annotate them). - Do not "fix" the warning with `configure({ enforceActions: "never" })`; that removes the signal without restoring batching. ## How this shows up in a todo store A typical sequence in the todo app: the list is an `observer` that reads `store.state` to show a spinner and `store.todos` to render rows. The user opens the screen, `load()` runs, and: 1. `this.state = 'pending'` runs inside the action; the list re-renders once with the spinner. 2. The request is in flight; the action has already returned a promise and ended. 3. The response arrives; without `runInAction`, `this.todos = data` notifies the list immediately while `state` is still `'pending'`, and a development warning names the modified observable. 4. `this.state = 'done'` notifies again. With `runInAction` around steps 3 and 4, both writes land together and every observer sees a consistent state: rows and status change at once. The same reasoning applies to WebSocket handlers, timers and any other callback that runs after the original event handler returned: each is a new entry point and needs its own action. ## Related pitfalls - Wrapping the **whole** async function in `action` does not help; the wrapper still ends at the first `await`. - Writing state in a React `useEffect` without an action has the same effect as writing after `await`. - Marking a pure lookup as `action` to silence warnings makes its reads untracked, so components calling it stop updating.

  • In MobX, what can go wrong when two related writes after an await run outside an action?
    Without a transaction, each write notifies affected reactions immediately, so a reaction or computed can see the in-between state, such as `todos` already replaced while `state` is still `'pending'`, and an `autorun` reading both runs twice. Inside `runInAction` both writes are applied first and reactions run once, after the outermost action ends.
  • How do you cancel a MobX flow that is still waiting on a request?
    Calling a flow returns a promise with a `cancel()` method. It interrupts the generator at its current `yield`, runs any `finally` blocks, and rejects the returned promise with a cancellation error. MobX also calls `cancel()` on the yielded promise if that promise has one; a plain `fetch` promise does not, so pass an `AbortSignal` if the request itself must stop.

saying these in an interview costs you the question

  • Annotating an async method as action covers everything after each await too.
  • MobX throws an error and discards the write when strict mode is violated.
  • Setting enforceActions to never is the correct fix for the warning.
  • The warning appears for every write, even when nothing observes the value.
  • flow is a separate library that must be installed alongside MobX.