Batching State Mutations in MobX: Actions and Transactions Explained
Learn how MobX actions and transactions group observable changes to prevent extra UI updates and keep devtools readable.
02 Sept 2026, 15:03 UTC

The problem: noisy reactions
When you mutate several observable fields in quick succession, MobX notifies every observer after each individual write. In a UI that reacts with autorun or observer components, this means the component re‑renders multiple times for a single logical update. The result is wasted work, flickering UI, and devtools logs that are hard to read.
Actions turn a function into a single unit
An action tells MobX “everything inside this function belongs together.” While the action runs, MobX suppresses notifications and delivers a single combined notification when the action finishes. This also gives the devtools a meaningful name for the whole operation.
import { makeAutoObservable, action, autorun } from "mobx";
class CounterStore {
count = 0;
constructor() {
makeAutoObservable(this, {
increment: action,
incrementTwice: action
});
}
increment() {
this.count += 1; // one mutation
}
incrementTwice() {
this.increment(); // still inside the same action
this.increment(); // second mutation
}
}
const store = new CounterStore();
let runs = 0;
autorun(() => {
runs++;
console.log("count changed to", store.count);
});
store.incrementTwice();
console.log("autorun runs:", runs); // → 1
Because incrementTwice is marked as an action, the two count writes produce only one autorun execution. If you removed the action wrapper, you would see two runs.
Transactions batch across multiple actions
Sometimes you need to group calls that are already separate actions—e.g., updating two different stores. transaction (or the low‑level runInAction) does exactly that: it delays notifications until the callback finishes.
import { transaction } from "mobx";
function transfer(from, to, amount) {
transaction(() => {
from.withdraw(amount); // action inside store A
to.deposit(amount); // action inside store B
});
}
Both withdraw and deposit are actions on their own stores, but the surrounding transaction guarantees that any observer watching either store receives a single notification after the whole transfer.
Async code needs runInAction
When an action performs asynchronous work, the mutations that happen after the await are no longer inside the original action scope. Wrap those mutations with runInAction to keep the batching guarantees.
import { action, runInAction } from "mobx";
class UserStore {
user = null;
@action async fetchUser(id) {
const data = await api.getUser(id);
runInAction(() => {
this.user = data; // mutation stays batched
});
}
}
Trade‑off: transactions don’t roll back
A transaction is only a notification barrier. If an error is thrown halfway through, the mutations that already executed stay visible to observers. You must manually revert them (e.g., in a catch block) if you need atomicity.
transaction(() => {
from.withdraw(amount);
// if this throws, `from` is already changed
to.deposit(amount);
});
How to verify the behavior
- Enable strict mode:
configure({ enforceActions: "always" }). Any mutation outside an action now throws, confirming that your wrappers are in place. - Open MobX devtools, perform a transaction, and check the timeline – you should see a single combined entry instead of many.
- Use the
autoruncounter pattern from the first example to count reaction runs before and after adding actions/transactions.
Takeaway
Wrap every state‑changing function in an action. When you need to coordinate several actions, wrap the group in a transaction. For async continuations, use runInAction. This keeps UI updates predictable, devtools readable, and performance tight—without changing your observable model.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.