Critical Race Condition Pattern in MobX
Critical Race Condition Pattern in MobX
When working with MobX stores in complex applications, there's a subtle but critical bug pattern that can cause hours of debugging frustration. This article explains the race condition that occurs when store methods recreate observable objects, and how to prevent it.
The Problem
When concurrent store operations recreate or replace observable objects, all previous object references become invalid. This creates a race condition where async operations continue working with stale references.
Root Cause
Store methods that replace or recreate observable instances can invalidate existing references. Any references held before that moment become stale — they point to objects that are no longer part of the store's state tree.
The Bug Pattern
Here's what the problematic code looks like:
public async updateSomething(id: string) {
const obj = this.getObjectById(id); // Store reference
obj.setLoading(true);
const data = await this.api.fetch(id);
// Meanwhile, another async operation recreates all objects
// (e.g., batch update, WebSocket sync, polling refresh) - 'obj' is now stale!
obj.updateData(data); // FAILS - operates on dead object
obj.setLoading(false); // FAILS - operates on dead object
}The timeline of this bug:
- Method starts, gets reference to object
- Sets loading state on the object
- Starts async API call
- During the await, another operation recreates all objects with fresh instances
- Async call completes
- Code tries to update the old object reference
- Updates are lost because they're applied to a detached object
The Solution
Always re-fetch objects by ID after any await call:
public async updateSomething(id: string) {
let obj = this.getObjectById(id);
if (!obj) return;
obj.setLoading(true);
const data = await this.api.fetch(id);
// Re-fetch object after async operation
obj = this.getObjectById(id);
if (obj) {
obj.updateData(data); // Works - uses current object
obj.setLoading(false); // Works - uses current object
}
}Prevention Rules
Follow these rules to avoid this race condition:
- Never store object references across async operations
- Always re-fetch by ID after
awaitcalls - Use ID-based lookups in async methods
MobX-State-Tree Connection
If you're using MobX-State-Tree, you may encounter this specific error message:
This is the same underlying issue — the object reference was detached when the tree was updated. MST is more explicit about this problem because it tracks object lifecycle, while vanilla MobX will silently allow operations on detached objects.
Alternative: Using flow Generators
MobX's flow function with generators can help structure async code more cleanly, though it doesn't prevent the stale reference issue when objects are recreated:
import { flow } from "mobx";
class MyStore {
updateSomething = flow(function* (this: MyStore, id: string) {
let obj = this.getObjectById(id);
if (!obj) return;
obj.setLoading(true);
const data = yield this.api.fetch(id);
// Still need to re-fetch after yield!
obj = this.getObjectById(id);
if (obj) {
obj.updateData(data);
obj.setLoading(false);
}
});
}The flow pattern is useful for automatically wrapping state updates in actions, but the core principle remains: always re-fetch object references after any yield/await.
Real-World Scenario
This bug commonly appears in applications with:
- Batch updates that recreate multiple objects at once
- Polling mechanisms that periodically refresh data
- WebSocket connections that sync state from the server
- Optimistic updates combined with server reconciliation
Any time your store has a method that bulk-replaces observable objects, you're at risk for this race condition.
Testing for This Bug
To verify your code handles this correctly:
it('should handle concurrent object recreation during async operation', async () => {
const store = new MyStore();
const id = 'test-id';
// Start async operation
const updatePromise = store.updateSomething(id);
// Simulate concurrent batch update that recreates objects
store.batchUpdateFromServer();
// Wait for async operation to complete
await updatePromise;
// Verify the update was applied to the current object
const obj = store.getObjectById(id);
expect(obj.data).toEqual(expectedData);
expect(obj.isLoading).toBe(false);
});Understanding this pattern will save you from mysterious bugs where state updates seem to disappear into thin air. Always remember: in MobX with async operations, object references are temporary — re-fetch after every await.