Шаг 6. Кастомные хуки
Финал React-части. Вся логика пока живёт в компонентах. Вынесем её в кастомные хуки — так она переиспользуется, тестируется и не засоряет разметку. Соберём три хука: useTasks (состояние + операции с задачами), useDebounce (антидребезг поиска), useLocalStorage (сохранение фильтра между сессиями).
Постановка задачи
Заголовок раздела «Постановка задачи»useTasks— инкапсулирует загрузку изtaskApi, состояние списка и операцииadd/toggle/remove. Возвращает данные и функции, компонент остаётся «тонким».useDebounce<T>(value, delay)— возвращает значение, отстающее наdelayмс; применяется к поисковому запросу, чтобы не фильтровать на каждое нажатие.useLocalStorage<T>(key, initial)— состояние, синхронизированное сlocalStorage; сохраним выбранный фильтр, чтобы он переживал перезагрузку.- собери всё в одном компоненте — он должен стать заметно короче.
Имя хука обязано начинаться с use — по этому React и ESLint применяют правила хуков.
Какие API и зачем
Заголовок раздела «Какие API и зачем»- Кастомный хук = функция, вызывающая другие хуки — выносит логику из компонента. Может содержать
useState,useEffect,useCallbackи т.д. useTasksобъединяетuseState(список + статус) иuseEffect(загрузка с защитой от гонок из шага 3). Компонент получает готовый интерфейс.useDebounceстроится наuseState+useEffect+setTimeout/clearTimeout: при каждом новом значении старый таймер сбрасывается.useLocalStorage— ленивая инициализацияuseState(() => ...)для чтения из хранилища один раз, плюс запись при изменении.
Показать эталонную реализацию
import { useCallback, useEffect, useState } from 'react';import type { Task, Filter } from '../types/task';import { taskApi } from '../api/taskApi';
// 1. useTasks — загрузка + CRUDtype Status = 'loading' | 'success' | 'error';
export function useTasks() { const [tasks, setTasks] = useState<Task[]>([]); const [status, setStatus] = useState<Status>('loading');
useEffect(() => { let ignore = false; const controller = new AbortController(); setStatus('loading');
taskApi .getTasks(controller.signal) .then((data) => { if (!ignore) { setTasks(data); setStatus('success'); } }) .catch((err) => { if (ignore) return; if (err instanceof DOMException && err.name === 'AbortError') return; setStatus('error'); });
return () => { ignore = true; controller.abort(); }; }, []);
const add = useCallback((title: string) => { const trimmed = title.trim(); if (!trimmed) return; setTasks((prev) => [ ...prev, { id: crypto.randomUUID(), title: trimmed, done: false, createdAt: Date.now() }, ]); }, []);
const toggle = useCallback((id: string) => { setTasks((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))); }, []);
const remove = useCallback((id: string) => { setTasks((prev) => prev.filter((t) => t.id !== id)); }, []);
return { tasks, status, add, toggle, remove };}
// 2. useDebounce — значение с задержкойexport function useDebounce<T>(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); // сброс старого таймера }, [value, delay]); return debounced;}
// 3. useLocalStorage — состояние, синхронизированное с localStorageexport function useLocalStorage<T>(key: string, initial: T) { const [value, setValue] = useState<T>(() => { try { const raw = localStorage.getItem(key); return raw !== null ? (JSON.parse(raw) as T) : initial; } catch { return initial; } });
useEffect(() => { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore quota errors */ } }, [key, value]);
return [value, setValue] as const;}
// Компонент стал тонким: вся логика в хукахexport function TaskApp() { const { tasks, status, add, toggle, remove } = useTasks(); const [filter, setFilter] = useLocalStorage<Filter>('task-filter', 'all'); const [search, setSearch] = useState(''); const debouncedSearch = useDebounce(search, 300);
if (status === 'loading') return <p>Загрузка...</p>; if (status === 'error') return <p>Ошибка загрузки</p>;
const query = debouncedSearch.trim().toLowerCase(); const visible = tasks .filter((t) => filter === 'active' ? !t.done : filter === 'done' ? t.done : true, ) .filter((t) => !query || t.title.toLowerCase().includes(query));
return ( <section> <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Поиск..." /> <div> {(['all', 'active', 'done'] as Filter[]).map((f) => ( <button key={f} disabled={filter === f} onClick={() => setFilter(f)}> {f} </button> ))} </div> <ul> {visible.map((task) => ( <li key={task.id}> <input type="checkbox" checked={task.done} onChange={() => toggle(task.id)} /> <span>{task.title}</span> <button onClick={() => remove(task.id)}>×</button> </li> ))} </ul> </section> );}Ловушки
Заголовок раздела «Ловушки»Часть E завершена
Заголовок раздела «Часть E завершена»Ты собрал Task Tracker на чистом React + TypeScript: состояние, производные данные, загрузка с защитой от гонок, оптимизация рендера, сложное состояние и переиспользуемые хуки.
Дальше — Часть F: тот же проект на MobX. Там мы перепишем это же приложение и почувствуем, чем подход MobX отличается от хуков.