Перейти к содержимому

MobX Stores

Базовый паттерн — класс с состоянием, computed и actions:

import { makeAutoObservable, runInAction } from 'mobx';
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
class UserStore {
users: User[] = [];
selectedId: number | null = null;
isLoading = false;
error: string | null = null;
constructor() {
makeAutoObservable(this);
}
// computed
get selectedUser(): User | null {
if (this.selectedId === null) return null;
return this.users.find(u => u.id === this.selectedId) ?? null;
}
get admins(): User[] {
return this.users.filter(u => u.role === 'admin');
}
// actions
select(id: number) {
this.selectedId = id;
}
deselect() {
this.selectedId = null;
}
// async action
async loadUsers() {
this.isLoading = true;
this.error = null;
try {
const users = await userApi.getAll();
runInAction(() => {
this.users = users;
this.isLoading = false;
});
} catch (err) {
runInAction(() => {
this.error = err instanceof Error ? err.message : 'Неизвестная ошибка';
this.isLoading = false;
});
}
}
async updateUser(id: number, data: Partial<User>) {
const updated = await userApi.update(id, data);
runInAction(() => {
const idx = this.users.findIndex(u => u.id === id);
if (idx !== -1) this.users[idx] = updated;
});
}
async deleteUser(id: number) {
await userApi.delete(id);
runInAction(() => {
this.users = this.users.filter(u => u.id !== id);
if (this.selectedId === id) this.selectedId = null;
});
}
}

Центральный объект, который содержит все сторы и обеспечивает доступ друг к другу:

class AuthStore {
token: string | null = null;
currentUser: User | null = null;
constructor(private root: RootStore) {
makeAutoObservable(this);
}
get isAuthenticated() {
return this.token !== null;
}
async login(email: string, password: string) {
const { token, user } = await authApi.login(email, password);
runInAction(() => {
this.token = token;
this.currentUser = user;
});
// Доступ к другим сторам через root
await this.root.userStore.loadUsers();
}
logout() {
this.token = null;
this.currentUser = null;
this.root.userStore.users = []; // очищаем данные при выходе
}
}
class UserStore {
users: User[] = [];
constructor(private root: RootStore) {
makeAutoObservable(this);
}
// Использование данных из другого стора
get currentUserProfile() {
const currentId = this.root.authStore.currentUser?.id;
if (!currentId) return null;
return this.users.find(u => u.id === currentId) ?? null;
}
async loadUsers() {
const token = this.root.authStore.token;
if (!token) throw new Error('Not authenticated');
const users = await userApi.getAll(token);
runInAction(() => { this.users = users; });
}
}
class RootStore {
authStore: AuthStore;
userStore: UserStore;
constructor() {
this.authStore = new AuthStore(this);
this.userStore = new UserStore(this);
}
}
// Создаём один экземпляр
export const rootStore = new RootStore();

Передача стора через React Context — лучший способ:

import React, { createContext, useContext } from 'react';
// Создание и передача через контекст
const RootStoreContext = createContext<RootStore | null>(null);
export function RootStoreProvider({ children }: React.PropsWithChildren) {
// Создаём один раз
const [store] = useState(() => new RootStore());
return (
<RootStoreContext.Provider value={store}>
{children}
</RootStoreContext.Provider>
);
}
// Типобезопасный хук
export function useRootStore(): RootStore {
const store = useContext(RootStoreContext);
if (!store) throw new Error('useRootStore must be used within RootStoreProvider');
return store;
}
// Хуки для отдельных сторов
export function useAuthStore() { return useRootStore().authStore; }
export function useUserStore() { return useRootStore().userStore; }
// Использование в компоненте
function UserList() {
const userStore = useUserStore();
useEffect(() => {
userStore.loadUsers();
}, [userStore]);
return (
<observer(function UserListInner() {
if (userStore.isLoading) return <Spinner />;
return <ul>{userStore.users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
})
);
}
import { autorun, toJS } from 'mobx';
class SettingsStore {
theme: 'light' | 'dark' = 'light';
language = 'ru';
notifications = true;
constructor() {
makeAutoObservable(this);
this.load();
}
private load() {
try {
const saved = localStorage.getItem('settings');
if (saved) {
const data = JSON.parse(saved);
Object.assign(this, data);
}
} catch { /* ignore */ }
}
// Автосохранение при каждом изменении
private setupPersistence() {
autorun(() => {
localStorage.setItem('settings', JSON.stringify(toJS(this)));
});
}
setTheme(theme: 'light' | 'dark') { this.theme = theme; }
setLanguage(language: string) { this.language = language; }
}
stores/__tests__/user-store.test.ts
import { UserStore, RootStore } from '../user-store';
describe('UserStore', () => {
let store: UserStore;
let root: RootStore;
beforeEach(() => {
root = new RootStore();
store = root.userStore;
});
it('should load users', async () => {
// Mock API
vi.spyOn(userApi, 'getAll').mockResolvedValue([
{ id: 1, name: 'Иван', email: 'ivan@test.ru', role: 'user' }
]);
await store.loadUsers();
expect(store.users).toHaveLength(1);
expect(store.isLoading).toBe(false);
expect(store.error).toBeNull();
});
it('computed admins filters correctly', () => {
store.users = [
{ id: 1, name: 'Иван', email: '', role: 'user' },
{ id: 2, name: 'Пётр', email: '', role: 'admin' },
];
expect(store.admins).toHaveLength(1);
expect(store.admins[0].name).toBe('Пётр');
});
});