Skip to content

Handling Asynchronous Operations in MobX

Posted on:March 9, 2025 at 06:22 PM

Handling Asynchronous Operations in MobX

Note: There are multiple approaches to handling asynchronous operations in MobX (like flow, runInAction etc). This blog post uses runInAction, but for a complete overview of all available options, please refer to the official MobX documentation.

Understanding Async Flows in MobX

In this guide, I’ll share my preferred approach to handling asynchronous operations in MobX, which includes the following techniques:

  1. Using runInAction with async/await
  2. Using the AsyncData pattern for state management
  3. Proper Promise handling and error management
  4. Type-safe async operations

The AsyncData class

The AsyncData provides a type-safe way to handle all possible states of an async operation, including promise cancellation and other utilities.

import { action, computed, makeObservable, observable } from 'mobx';
import { canceledPromise, CanceledPromise } from '../utils/networkUtils';

export type Status = 'pending' | 'done' | 'error';

export class AsyncDataStore<T> {
    private request: CanceledPromise<[Response | null, T | undefined]> | undefined;

    data: T;
    status: Status = 'done';
    error: any;

    constructor(initData: T) {
        this.data = initData;
        makeObservable(this, {
            data: observable,
            status: observable,
            error: observable,
            setStatus: action,
            setData: action,
            setError: action,
            loading: computed,
        });
    }

    private cancelRequest() {
        if (this.request) {
            this.request.cancel();
        }
    }

    setStatus(status: Status) {
        return (this.status = status);
    }

    setData(data: T) {
        this.cancelRequest();
        this.setStatus('done');
        return (this.data = data);
    }

    setError(error: any) {
        return (this.error = error);
    }

    resetStore() {
        this.setData(undefined);
        this.setError(undefined);
    }

    get loading() {
        return this.status === 'pending';
    }

    async loadData(fetchData: Promise<T | undefined>) {
        this.cancelRequest();
        this.setStatus('pending');
        this.request = canceledPromise(() => fetchData);
        const response = await this.request.promise;

        if (response && response[0]) {
            this.setError(response[0]);
            this.setStatus('error');
        }
        if (response && response[1]) {
            this.setData(response[1]);
        }
        return response;
    }
}

Implementation Example

Here’s how to use the AsyncDataStore class in your store:

import { makeAutoObservable } from 'mobx';

interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

export class TodoStore {
    todosAsync = new AsyncDataStore<Todo[]>([]);
    createTodoAsync = new AsyncDataStore<Todo | null>(null);

    constructor() {
        makeAutoObservable(this);
    }

    async fetchTodos(): Promise<[Response | null, Todo[] | undefined]> {
        return this.todosAsync.loadData(
            fetch('https://api.example.com/todos')
                .then(res => res.json())
        );
    }

    async createTodo(text: string): Promise<[Response | null, Todo | undefined]> {
        return this.createTodoAsync.loadData(
            fetch('https://api.example.com/todos', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ text, completed: false })
            })
                .then(res => res.json())
                .then(newTodo => {
                    // Update todos list if we have it
                    if (this.todosAsync.data) {
                        this.todosAsync.setData([...this.todosAsync.data, newTodo]);
                    }
                    return newTodo;
                })
        );
    }

    // Cleanup method to cancel pending requests
    cleanup() {
        this.todosAsync.resetStore();
        this.createTodoAsync.resetStore();
    }
}

Using the Store in Components

The AsyncDataStore class makes component implementation cleaner:

import { observer } from 'mobx-react-lite';
import { useStore } from '../stores';

const TodoList = observer(() => {
    const { todoStore } = useStore();

    useEffect(() => {
        todoStore.fetchTodos();

        return () => todoStore.todosAsync.resetStore();
    }, [todoStore]);

    if (todoStore.todosAsync.loading) {
        return <div>Loading todos...</div>;
    }

    if (todoStore.todosAsync.error) {
        return <div>Error: {todoStore.todosAsync.error.message}</div>;
    }

    return (
        <div>
            {todoStore.todosAsync.data?.map(todo => (
                <TodoItem key={todo.id} todo={todo} />
            ))}
        </div>
    );
});

const CreateTodo = observer(() => {
    const { todoStore } = useStore();
    const [text, setText] = useState('');

    const handleSubmit = async (e) => {
        e.preventDefault();
        const [error] = await todoStore.createTodo(text);
        if (!error) {
            setText('');
        }
    };

    useEffect(() => {
        return () => todoStore.createTodoAsync.resetStore();
    }, [todoStore]);

    return (
        <form onSubmit={handleSubmit}>
            <input
                value={text}
                onChange={e => setText(e.target.value)}
                disabled={todoStore.createTodoAsync.loading}
            />
            <button
                type="submit"
                disabled={todoStore.createTodoAsync.loading}
            >
                {todoStore.createTodoAsync.loading
                    ? 'Creating...'
                    : 'Create Todo'}
            </button>
            {todoStore.createTodoAsync.error && (
                <div>Error: {todoStore.createTodoAsync.error.message}</div>
            )}
        </form>
    );
});

Good to know

  1. Always Return Promises

    // ✅ Good: Return Promise and handle errors
    async fetchData(): Promise<Data> {
        try {
            const response = await api.getData();
            runInAction(() => {
                this.data = response;
            });
            return response;
        } catch (error) {
            runInAction(() => {
                this.error = error;
            });
            throw error;
        }
    }
  2. Use runInAction for State Updates

    // ✅ Good: State updates wrapped in runInAction
    async updateData() {
        const data = await api.getData();
        runInAction(() => {
            this.data = data;
            this.lastUpdated = new Date();
        });
    }
  3. Handle Loading States

    // ✅ Good: Proper loading state management
    async fetchData() {
        runInAction(() => {
            this.state = { status: 'loading' };
        });
    
        try {
            const data = await api.getData();
            runInAction(() => {
                this.state = { status: 'success', data };
            });
        } catch (error) {
            runInAction(() => {
                this.state = { status: 'error', error };
            });
        }
    }