Handling Asynchronous Operations in MobX
Note: There are multiple approaches to handling asynchronous operations in MobX (like
flow
,runInAction
etc). This blog post usesrunInAction
, 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:
- Using
runInAction
with async/await - Using the AsyncData pattern for state management
- Proper Promise handling and error management
- 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
-
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; } }
-
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(); }); }
-
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 }; }); } }