← Back to Blog

Critical Race Condition Pattern in MobX

·5 min read
mobxreacttypescriptdebuggingstate management

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:

  1. Method starts, gets reference to object
  2. Sets loading state on the object
  3. Starts async API call
  4. During the await, another operation recreates all objects with fresh instances
  5. Async call completes
  6. Code tries to update the old object reference
  7. 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:

  1. Never store object references across async operations
  2. Always re-fetch by ID after await calls
  3. 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.

Resources

MobX Actions Documentationmobx.js.org
MST Race Condition Issue #416github.com
Understanding MobX Reactivitymobx.js.org