MobX Паттерны и best practices
Async actions: flow vs runInAction
Заголовок раздела «Async actions: flow vs runInAction»Две стратегии для асинхронных actions:
runInAction (простые случаи)
Заголовок раздела «runInAction (простые случаи)»import { makeAutoObservable, runInAction } from 'mobx';
class ProductStore { products: Product[] = []; isLoading = false;
constructor() { makeAutoObservable(this); }
async loadProducts() { this.isLoading = true; try { const data = await productApi.getAll(); runInAction(() => { this.products = data; this.isLoading = false; }); } catch (err) { runInAction(() => { this.isLoading = false; }); throw err; } }}flow (генераторы — рекомендуется)
Заголовок раздела «flow (генераторы — рекомендуется)»flow автоматически оборачивает каждый шаг после await в action:
import { makeObservable, observable, action, flow } from 'mobx';
class ProductStore { products: Product[] = []; isLoading = false;
constructor() { makeObservable(this, { products: observable, isLoading: observable, loadProducts: flow, // генератор — объявляем как flow }); }
// Генератор вместо async function *loadProducts() { this.isLoading = true; try { // yield вместо await const data: Product[] = yield productApi.getAll(); this.products = data; // ✅ не нужен runInAction! this.isLoading = false; } catch (err) { this.isLoading = false; throw err; } }}
// С makeAutoObservable — использовать flowResult для типизацииimport { makeAutoObservable, flow, flowResult } from 'mobx';
class Store { data: string[] = [];
constructor() { makeAutoObservable(this, { fetchData: flow }); }
*fetchData(query: string) { const result: string[] = yield fetch(`/api?q=${query}`).then(r => r.json()); this.data = result; return result; }}
// Использование с типизациейconst store = new Store();const result = await flowResult(store.fetchData('react'));Отмена flow (AbortController)
Заголовок раздела «Отмена flow (AbortController)»class SearchStore { results: string[] = []; query = ''; private abortController: AbortController | null = null;
constructor() { makeAutoObservable(this, { search: flow }); }
setQuery(query: string) { this.query = query; // Отменяем предыдущий запрос this.abortController?.abort(); }
*search() { this.abortController = new AbortController(); try { const data: string[] = yield fetch( `/api/search?q=${this.query}`, { signal: this.abortController.signal } ).then(r => r.json()); this.results = data; } catch (err) { if ((err as Error).name !== 'AbortError') throw err; } }}Реакции в компонентах
Заголовок раздела «Реакции в компонентах»import { reaction, autorun } from 'mobx';import { useEffect } from 'react';import { observer } from 'mobx-react-lite';
// Выполнить side-effect при изменении observable (логирование, аналитика, API)const UserForm = observer(function UserForm() { const store = useUserStore();
// reaction в useEffect — правильная интеграция useEffect(() => { const disposer = reaction( () => store.currentUser?.id, (userId) => { if (userId) { analytics.identify(userId); } } ); return disposer; // cleanup при размонтировании }, [store]);
return <form>...</form>;});
// Синхронизация MobX state → URLfunction useUrlSync(store: FilterStore) { useEffect(() => { const disposer = autorun(() => { const params = new URLSearchParams(); if (store.query) params.set('q', store.query); if (store.page > 1) params.set('page', String(store.page)); window.history.replaceState(null, '', `?${params}`); }); return disposer; }, [store]);}Типичные ошибки
Заголовок раздела «Типичные ошибки»Ошибка 1: Изменение observable вне action в strict mode
Заголовок раздела «Ошибка 1: Изменение observable вне action в strict mode»configure({ enforceActions: 'always' });
class Store { count = 0; constructor() { makeAutoObservable(this); }}
const store = new Store();
// ❌ Ошибка: изменение вне actionsetTimeout(() => { store.count++; // MobX: [mobx] Since strict-mode is enabled, changing observed values...}, 1000);
// ✅ Правильноclass Store2 { count = 0; constructor() { makeAutoObservable(this); } increment() { this.count++; } // action}setTimeout(() => store2.increment(), 1000);Ошибка 2: Computed с side effects
Заголовок раздела «Ошибка 2: Computed с side effects»class BadStore { users: User[] = []; constructor() { makeAutoObservable(this); }
// ❌ computed не должен иметь side effects! get userCount() { console.log('calculating...'); // плохо — логирование fetch('/api/log'); // ОЧЕНЬ плохо — запрос в computed! return this.users.length; }}
// ✅ computed — только чистые вычисленияclass GoodStore { users: User[] = []; constructor() { makeAutoObservable(this); }
get userCount() { return this.users.length; // только вычисление }}Ошибка 3: Передача observable как prop без observer
Заголовок раздела «Ошибка 3: Передача observable как prop без observer»interface CardProps { user: User; // User — MobX observable object}
// ❌ Не observer — user.name может обновиться, но компонент не перерисуетсяfunction UserCard({ user }: CardProps) { return <div>{user.name}</div>;}
// ✅ Обернуть в observerconst UserCard = observer(function UserCard({ user }: CardProps) { return <div>{user.name}</div>;});Ошибка 4: Создание Store в компоненте без useState
Заголовок раздела «Ошибка 4: Создание Store в компоненте без useState»// ❌ Новый store при каждом рендере!function BadComponent() { const store = new ItemStore(); // пересоздаётся при каждом рендере return <observer>...</observer>;}
// ✅ useState для стабильной ссылкиfunction GoodComponent() { const [store] = useState(() => new ItemStore()); return <observer>...</observer>;}Best practices
Заголовок раздела «Best practices»// 1. Один store — одна зона ответственностиclass AuthStore { /* только авторизация */ }class CartStore { /* только корзина */ }class UIStore { /* только UI состояние (modals, toasts) */ }
// 2. Держи производное состояние в computed, не в stateclass BadStore { users: User[] = []; adminCount = 0; // ❌ избыточно — выводится из users
addUser(user: User) { this.users.push(user); if (user.role === 'admin') this.adminCount++; // синхронизировать вручную }}
class GoodStore { users: User[] = []; constructor() { makeAutoObservable(this); }
get adminCount() { // ✅ computed — всегда актуально return this.users.filter(u => u.role === 'admin').length; }}
// 3. toJS для serialization / передачи в non-observable контекстimport { toJS } from 'mobx';
function serializeStore(store: UserStore) { return JSON.stringify(toJS(store.users)); // Без toJS JSON.stringify может не обойти Proxy MobX}