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

Шаг 2. computed + observer

Эквивалент шага E2 (useMemo), но без массивов зависимостей. Добавим фильтр, поиск и счётчики как computed, а компоненты сделаем реактивными через observer.

  • Добавь в TaskStore наблюдаемые поля filter: Filter и search: string + actions setFilter, setSearch;
  • добавь computed-геттеры: filteredTasks (фильтр all/active/done + поиск) и счётчики total, activeCount, doneCount;
  • оберни компоненты, читающие стор, в observer — они должны перерисовываться автоматически при изменении наблюдаемых;
  • соблюдай правило «читай через точку»: обращайся к store.filteredTasks, не деструктурируй наблюдаемые.

Тип Filter — из src/types/task.ts.

  • computed (через get-геттер) — производное состояние. makeAutoObservable делает геттеры вычисляемыми: они кэшируются и пересчитываются только при изменении наблюдаемых, которые читают. Прямой аналог useMemo, но зависимости отслеживаются сами.
  • observer(Component) из mobx-react-lite — подписывает компонент на наблюдаемые, которые он читает во время рендера, и перерисовывает при их изменении. Нужен на каждом компоненте, который читает стор.
  • Чтение «через точку»observer отслеживает чтения наблюдаемых. Если деструктурировать (const { filteredTasks } = store), значение «отвязывается» от наблюдаемого, и реактивность теряется. Читай store.filteredTasks прямо в JSX.
Показать эталонную реализацию
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
import type { Task, Filter } from '../types/task';
export class TaskStore {
tasks: Task[] = [];
filter: Filter = 'all';
search = '';
constructor() {
makeAutoObservable(this);
}
// actions
addTask(title: string) {
const trimmed = title.trim();
if (!trimmed) return;
this.tasks.push({
id: crypto.randomUUID(),
title: trimmed,
done: false,
createdAt: Date.now(),
});
}
toggleTask(id: string) {
const task = this.tasks.find((t) => t.id === id);
if (task) task.done = !task.done;
}
removeTask(id: string) {
this.tasks = this.tasks.filter((t) => t.id !== id);
}
setFilter(filter: Filter) {
this.filter = filter;
}
setSearch(search: string) {
this.search = search;
}
// computed — производное состояние, кэшируется, deps отслеживаются сами
get filteredTasks(): Task[] {
const byFilter = this.tasks.filter((t) => {
if (this.filter === 'active') return !t.done;
if (this.filter === 'done') return t.done;
return true;
});
const query = this.search.trim().toLowerCase();
if (!query) return byFilter;
return byFilter.filter((t) => t.title.toLowerCase().includes(query));
}
get total() {
return this.tasks.length;
}
get activeCount() {
return this.tasks.filter((t) => !t.done).length;
}
get doneCount() {
return this.total - this.activeCount;
}
}
export const taskStore = new TaskStore();
// observer: компонент реактивен. Читаем строго через точку (store.xxx)
export const TaskBoard = observer(function TaskBoard() {
const store = taskStore;
const filters: Filter[] = ['all', 'active', 'done'];
return (
<div>
<input
value={store.search}
onChange={(e) => store.setSearch(e.target.value)}
placeholder="Поиск..."
/>
<div>
{filters.map((f) => (
<button
key={f}
disabled={store.filter === f}
onClick={() => store.setFilter(f)}
>
{f} ({f === 'all' ? store.total : f === 'active' ? store.activeCount : store.doneCount})
</button>
))}
</div>
<ul>
{store.filteredTasks.map((task) => (
<li key={task.id}>
<input
type="checkbox"
checked={task.done}
onChange={() => store.toggleTask(task.id)}
/>
<span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
{task.title}
</span>
<button onClick={() => store.removeTask(task.id)}>×</button>
</li>
))}
</ul>
</div>
);
});

Дальше — Шаг 3: async и RootStore: загрузка из taskApi, RootStore + Context, персист.