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

Шаг 5. Сложное состояние (useReducer + useContext)

Состояние разрослось: задачи, фильтр, поиск, плюс несколько операций. Разрозненные useState и проброс обработчиков через несколько уровней (prop-drilling) становятся неудобными. Соберём всё в useReducer и раздадим через useContext.

  • Опиши состояние приложения одним объектом: { tasks, filter, search };
  • опиши действия размеченным объединением (discriminated union): add, toggle, remove, setFilter, setSearch;
  • напиши чистый reducer, обрабатывающий каждое действие иммутабельно;
  • создай Context, который отдаёт state и dispatch, и провайдер TaskProvider;
  • напиши типобезопасный хук useTaskContext, бросающий ошибку вне провайдера;
  • любой вложенный компонент (форма, список, фильтры) берёт нужное через хук, без проброса пропсов.

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

  • useReducer(reducer, initialState) — когда состояние сложное (несколько связанных полей) и обновления нетривиальны, reducer собирает всю логику в одном чистом месте. Возвращает [state, dispatch].
  • Discriminated union для actions — каждое действие имеет поле type; TypeScript по нему сужает тип и проверяет полноту switch. Невозможно отправить действие с неверным payload.
  • Чистый reducer — функция (state, action) => newState без побочных эффектов. Её легко тестировать отдельно от React.
  • createContext + useContext — раздаёт state и dispatch вглубь дерева без prop-drilling. Дефолт null + проверка в хуке ловят использование вне провайдера.
Показать эталонную реализацию
import { createContext, useContext, useReducer, type Dispatch } from 'react';
import type { Task, Filter } from '../types/task';
// 1. Состояние
interface State {
tasks: Task[];
filter: Filter;
search: string;
}
// 2. Действия — discriminated union
type Action =
| { type: 'add'; title: string }
| { type: 'toggle'; id: string }
| { type: 'remove'; id: string }
| { type: 'setFilter'; filter: Filter }
| { type: 'setSearch'; search: string };
const initialState: State = { tasks: [], filter: 'all', search: '' };
// 3. Чистый reducer
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'add': {
const title = action.title.trim();
if (!title) return state;
const task: Task = {
id: crypto.randomUUID(),
title,
done: false,
createdAt: Date.now(),
};
return { ...state, tasks: [...state.tasks, task] };
}
case 'toggle':
return {
...state,
tasks: state.tasks.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t,
),
};
case 'remove':
return { ...state, tasks: state.tasks.filter((t) => t.id !== action.id) };
case 'setFilter':
return { ...state, filter: action.filter };
case 'setSearch':
return { ...state, search: action.search };
default:
return state; // exhaustiveness: TS проверит, что все type обработаны
}
}
// 4. Context
interface TaskContextValue {
state: State;
dispatch: Dispatch<Action>;
}
const TaskContext = createContext<TaskContextValue | null>(null);
export function TaskProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<TaskContext.Provider value={{ state, dispatch }}>
{children}
</TaskContext.Provider>
);
}
// 5. Типобезопасный хук
export function useTaskContext(): TaskContextValue {
const ctx = useContext(TaskContext);
if (!ctx) throw new Error('useTaskContext must be used within TaskProvider');
return ctx;
}
// Любой вложенный компонент берёт нужное без prop-drilling:
export function FilterTabs() {
const { state, dispatch } = useTaskContext();
const filters: Filter[] = ['all', 'active', 'done'];
return (
<div>
{filters.map((f) => (
<button
key={f}
disabled={state.filter === f}
onClick={() => dispatch({ type: 'setFilter', filter: f })}
>
{f}
</button>
))}
</div>
);
}

Дальше — Шаг 6: кастомные хуки, где вынесем логику в переиспользуемые хуки.