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?
answer
- an action ends at the first await
- the default strictness level
- a warning, not a failure
- wrap the continuation
- generators instead of await
basics
~20 sAn 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 sIn 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 linesimport { 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
Know that state changes belong in actions, and that code after await needs runInAction or a flow to count as one.
Explain why an action ends at the first await, what enforceActions "observed" checks, and what batching is lost when writes run outside an action.
Choose between runInAction, action-wrapped callbacks and flow for real async processes, including cancellation and error states, without disabling strict mode.
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.