Preventing Render Thrashing in MobX with Action Batching
Learn how to use MobX actions to batch state mutations, prevent render thrashing in React, and enforce strict state consistency using enforceActions.
10 Mar 2026, 00:59 UTC

The Cost of Frequent State Mutations
In a complex React application, a single user interaction often requires updating multiple pieces of state. If you update three different observable properties sequentially, a naive reactive system might trigger three separate component re-renders. This "render thrashing" degrades performance and can lead to visible UI flickering.
The solution in MobX is the Action. An action is not just a naming convention; it is a mechanism that batches multiple state mutations into a single transaction. The takeaway is simple: any logic that modifies observable state should be wrapped in an action to ensure the UI updates exactly once per logical operation.
How MobX Batches Updates
MobX uses a Transparent Functional Reactive Programming (TFRP) model. When an observable (a piece of state that can be tracked) changes, MobX notifies all observers (usually React components wrapped in observer from mobx-react-lite) that depend on that specific piece of data.
Without an action, every single assignment to an observable triggers an immediate notification. When you wrap these assignments in an action, MobX intercepts the notifications and queues them. It only flushes these notifications to the observers once the action function has fully executed. This transforms multiple granular updates into one atomic state transition.
Implementing the Action Pattern
Modern MobX (version 6+) provides makeAutoObservable, which automatically infers that methods in your store class are actions. This reduces boilerplate while maintaining the batching benefits.
Worked Example: User Profile Update
Consider a scenario where updating a user's profile requires changing the username, the email, and a "last updated" timestamp simultaneously.
import { makeAutoObservable } from "mobx";
class UserStore {
username = "Guest";
email = "";
lastUpdated = null;
constructor() {
// makeAutoObservable marks methods as actions by default
makeAutoObservable(this);
}
// This is an action. All mutations inside are batched.
updateProfile(newUsername, newEmail) {
this.username = newUsername;
this.email = newEmail;
this.lastUpdated = new Date();
// Only one re-render is triggered here, despite three mutations.
}
}
export const userStore = new UserStore();
Enforcing State Consistency
To prevent "leakage"—where developers accidentally mutate state inside a component or a utility function—you can enforce that all mutations happen within actions. Run this configuration at the entry point of your application (e.g., index.js):
import { configure } from "mobx";
configure({
enforceActions: "always",
});
Risk: With enforceActions: "always", any attempt to modify an observable outside of an action will throw a runtime error. This is highly recommended for production apps to maintain a predictable data flow.
Trade-offs and Limitations
While actions solve the rendering problem, they introduce a specific challenge with asynchronous code. MobX actions are synchronous. If you have an async function, only the code before the first await is batched. Mutations occurring after the await are treated as separate transactions.
To handle this, you must wrap the post-await mutations in a new action or use runInAction:
import { runInAction } from "mobx";
async fetchUser() {
const data = await api.getUser();
// This mutation is outside the original action scope
runInAction(() => {
this.username = data.name;
this.email = data.email;
});
}
Verifying the Result
To verify that batching is working, you can place a console.log("Rendered") inside the render body of a React component observing your store. Without an action, updating three properties will print "Rendered" three times. With an action, it will print only once.
If you are seeing unexpected re-renders, check if your mutations are happening inside async blocks without runInAction, or if you are accidentally creating new object references in computed values, which can trigger downstream observers.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.