MobX Transactions: Batching Synchronous Updates to Prevent Reaction Storms
Multiple observable updates in one tick trigger duplicate reactions. MobX's transaction API batches synchronous changes into a single flush, cutting redundant UI work—if you respect its async boundaries.
09 Aug 2026, 14:45 UTC

The Reaction Storm Problem
You wire up a MobX store to a React component. A user submits a form, and your handler updates three observables: firstName, lastName, and email. Each assignment fires its own reaction. Your component re-renders three times in the same event loop tick, your autorun logs three times, and your derived state recalculates redundantly. For a single logical state change, the UI pays three times.
MobX gives you a surgical tool for this: transaction. It batches synchronous observable updates so reactions run once, after the block finishes.
How transaction Batches Synchronous Work
Import transaction from mobx and wrap your state changes:
import { transaction } from "mobx";
transaction(() => {
store.firstName = "Ada";
store.lastName = "Lovelace";
store.email = "[contact removed]";
});
Inside the block, observables update normally. What changes is notification: derived state (computed values) and side-effect trackers (reactions such as autorun, reaction, and when) are deferred until the outermost transaction exits. If you nest transactions, inner blocks do not flush reactions; only the final exit of the outermost wrapper triggers a single notification pass.
This matters most in UI frameworks. React components subscribed to multiple observables can coalesce what would have been several renders into one, cutting CPU work when many observables change together.
A Worked Example
The following script illustrates the batching effect. Run it in a Node environment with MobX 6.x installed (npm i mobx@6). The code is illustrative; verify the counts in your own console or test runner.
import { observable, autorun, transaction, configure } from "mobx";
configure({ enforceActions: true });
const store = observable({
count: 0,
});
let runs = 0;
autorun(() => {
runs++;
console.log(`autorun fired: count=${store.count}`);
});
// Without transaction: three separate reactions
store.count = 1;
store.count = 2;
store.count = 3;
console.log(`runs without transaction: ${runs}`);
// Reset
runs = 0;
store.count = 0;
// With transaction: one reaction after the block
transaction(() => {
store.count = 1;
store.count = 2;
store.count = 3;
});
console.log(`runs with transaction: ${runs}`);
Expected behavior: the first block logs three times, and runs reads 3. The transaction block logs once, and runs reads 1. If you see identical counts in both cases, confirm you are on MobX 6.x and that the increments are truly synchronous.
The Async Boundary and Strict Mode
transaction only wraps synchronous code. If you schedule an update inside the block with setTimeout, a promise, or an async callback, that update lands after the transaction has already flushed. Reactions fire immediately for those later mutations.
transaction(() => {
store.count = 1;
setTimeout(() => {
store.count = 2; // fires a separate reaction
}, 0);
});
There is also a safety trade-off. In MobX's default non-strict mode, transaction can quietly mask unintended side effects because it suppresses intermediate reactions. Enable strict mode to keep updates explicit:
configure({ enforceActions: true });
With strict mode active, direct mutations outside actions throw. You can still use transaction to batch work, but pair it with runInAction or class-based actions so MobX knows the mutation is intentional.
When to Reach for transaction
Use transaction when a single user gesture or network response touches multiple observables and you want one reaction pass: hydrating a form from an API payload, applying a bulk selection, or updating a drag position across several fields. Skip it for isolated single-field updates, and never rely on it to batch asynchronous work—structure those flows with explicit actions or separate transactions instead.
To check the result in your own app, add a temporary autorun that counts invocations, or use React DevTools to confirm a component renders once per logical update rather than once per field. If the count matches your expectations, the batching is working as intended.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.