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

Шаг 6. Кастомные хуки

Финал React-части. Вся логика пока живёт в компонентах. Вынесем её в кастомные хуки — так она переиспользуется, тестируется и не засоряет разметку. Соберём три хука: useTasks (состояние + операции с задачами), useDebounce (антидребезг поиска), useLocalStorage (сохранение фильтра между сессиями).

  • useTasks — инкапсулирует загрузку из taskApi, состояние списка и операции add/toggle/remove. Возвращает данные и функции, компонент остаётся «тонким».
  • useDebounce<T>(value, delay) — возвращает значение, отстающее на delay мс; применяется к поисковому запросу, чтобы не фильтровать на каждое нажатие.
  • useLocalStorage<T>(key, initial) — состояние, синхронизированное с localStorage; сохраним выбранный фильтр, чтобы он переживал перезагрузку.
  • собери всё в одном компоненте — он должен стать заметно короче.

Имя хука обязано начинаться с use — по этому React и ESLint применяют правила хуков.

  • Кастомный хук = функция, вызывающая другие хуки — выносит логику из компонента. Может содержать 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 — загрузка + CRUD
type 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 — состояние, синхронизированное с localStorage
export 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>
);
}

Ты собрал Task Tracker на чистом React + TypeScript: состояние, производные данные, загрузка с защитой от гонок, оптимизация рендера, сложное состояние и переиспользуемые хуки.

Дальше — Часть F: тот же проект на MobX. Там мы перепишем это же приложение и почувствуем, чем подход MobX отличается от хуков.