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

Шаг 2. Производное состояние (useMemo)

Добавим переключатель фильтра (все / активные / выполненные), строку поиска и счётчики. Ключевая идея шага: отфильтрованный список и счётчики не хранятся в состоянии — они вычисляются из задач во время рендера.

Расширь TaskApp из шага 1:

  • добавь состояние filter: Filter ('all' | 'active' | 'done') и кнопки переключения;
  • добавь состояние search: string и поле поиска;
  • вычисли видимый список задач: сначала применяется фильтр, потом поиск по title (без учёта регистра);
  • покажи счётчики: всего задач, активных, выполненных;
  • список и счётчики должны быть производными от tasks + filter + search, а не отдельным состоянием.

Тип Filter берётся из src/types/task.ts.

  • useState<Filter> и useState<string> — фильтр и поисковый запрос это состояние UI: их меняет пользователь.
  • Вычисление во время рендераvisibleTasks и счётчики выводятся из существующего состояния. Хранить их в отдельном useState + синхронизировать через эффект — антипаттерн (лишний рендер, рассинхрон). Просто посчитай в теле компонента.
  • useMemo — нужен, только если вычисление дорогое (большой список, тяжёлая фильтрация) или результат передаётся в мемоизированный дочерний компонент. Мемоизация кэширует результат и пересчитывает его лишь при изменении зависимостей. Для маленького списка useMemo избыточен — но мы покажем его как привычку для роста списка.
Показать эталонную реализацию
import { useMemo, useState } from 'react';
import type { Task, Filter } from '../types/task';
interface Props {
tasks: Task[];
onToggle: (id: string) => void;
onRemove: (id: string) => void;
}
export function TaskBoard({ tasks, onToggle, onRemove }: Props) {
const [filter, setFilter] = useState<Filter>('all');
const [search, setSearch] = useState('');
// Видимый список — производное состояние. useMemo оправдан: фильтр + поиск
// могут быть дорогими на большом списке, пересчитываем только при изменении deps.
const visibleTasks = useMemo(() => {
const byFilter = tasks.filter((t) => {
if (filter === 'active') return !t.done;
if (filter === 'done') return t.done;
return true; // 'all'
});
const query = search.trim().toLowerCase();
if (!query) return byFilter;
return byFilter.filter((t) => t.title.toLowerCase().includes(query));
}, [tasks, filter, search]);
// Счётчики — тоже производные. Здесь вычисление дешёвое, useMemo не нужен.
const total = tasks.length;
const activeCount = tasks.filter((t) => !t.done).length;
const doneCount = total - activeCount;
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск..."
/>
<div>
<button onClick={() => setFilter('all')} disabled={filter === 'all'}>
Все ({total})
</button>
<button onClick={() => setFilter('active')} disabled={filter === 'active'}>
Активные ({activeCount})
</button>
<button onClick={() => setFilter('done')} disabled={filter === 'done'}>
Выполнено ({doneCount})
</button>
</div>
<ul>
{visibleTasks.map((task) => (
<li key={task.id}>
<input
type="checkbox"
checked={task.done}
onChange={() => onToggle(task.id)}
/>
<span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
{task.title}
</span>
<button onClick={() => onRemove(task.id)}>×</button>
</li>
))}
</ul>
{visibleTasks.length === 0 && <p>Ничего не найдено</p>}
</div>
);
}

Дальше — Шаг 3: загрузка данных и useEffect: подключим taskApi, состояния loading/error и разберём гонки запросов.