Хук useReducer: структуроване управління складним станом
Хук useReducer: структуроване управління складним станом
🔗 Офіційний референс: Витягування логіки стану в редюсер (Extracting State Logic into a Reducer)
🎯 Мета лекції
- Зрозуміти, коли
useReducerстає кращим вибором порівняно зuseState. - Опанувати концепцію редюсера як чистої функції переходу стану.
- Навчитися проєктувати систему екшенів (actions) для складних інтерфейсів.
- Застосувати TypeScript для безпечної типізації екшенів через дискримінантні об'єднання (discriminated unions).
🔑 Ключові терміни
- Редюсер (Reducer): чиста функція
(state, action) => newState, яка описує всі можливі переходи стану. - Екшен (Action): об'єкт із типом (
type) та опціональними даними (payload), що описує намір користувача. - Dispatch: функція для надсилання екшенів у редюсер — аналог
setState, але декларативний. - Дискримінантне об'єднання (Discriminated Union): TypeScript-патерн для типізації екшенів через спільну властивість
type.
Від розсіяних setState до централізованої логіки
Коли компонент управляє простим станом — одним числом чи булевим прапорцем, — хук useState є ідеальним інструментом. Проте у міру зростання складності інтерфейсу — коли стан містить десятки полів, а одна дія користувача змінює кілька частин стану одночасно — код швидко перетворюється на безладну суміш розсіяних викликів setState.
Розглянемо типову форму реєстрації користувача з валідацією:
import { useState } from "react";
interface FormState {
email: string;
password: string;
confirmPassword: string;
agreedToTerms: boolean;
isSubmitting: boolean;
errors: {
email?: string;
password?: string;
confirmPassword?: string;
};
}
function RegistrationForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<FormState["errors"]>({});
const handleEmailChange = (value: string) => {
setEmail(value);
// Очищення помилки при редагуванні
if (errors.email) {
setErrors((prev) => ({ ...prev, email: undefined }));
}
};
const handlePasswordChange = (value: string) => {
setPassword(value);
if (errors.password) {
setErrors((prev) => ({ ...prev, password: undefined }));
}
};
const handleSubmit = async () => {
setIsSubmitting(true);
setErrors({});
// Валідація
const newErrors: FormState["errors"] = {};
if (!email.includes("@")) {
newErrors.email = "Невірний формат email";
}
if (password.length < 8) {
newErrors.password = "Пароль має містити мінімум 8 символів";
}
if (password !== confirmPassword) {
newErrors.confirmPassword = "Паролі не збігаються";
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
setIsSubmitting(false);
return;
}
// Відправка даних...
await submitForm({ email, password });
setIsSubmitting(false);
};
// JSX-розмітка опущена для стислості...
}
Навіть у цьому спрощеному прикладі виникають системні проблеми:
- Розсіяння логіки: кожен обробник події містить дублюючу логіку очищення помилок.
- Синхронізація стану: якщо забути оновити одну з пов'язаних змінних, стан стає неконсистентним.
- Неможливість відстеження історії: складно зрозуміти, яка послідовність дій призвела до поточного стану.
- Тестування: для тестування логіки доводиться монтувати весь компонент і симулювати послідовності подій.
useState стає обтяжливим, — це три або більше пов'язаних змінних стану, які часто оновлюються разом. Якщо ви регулярно пишете setState1(...); setState2(...); setState3(...) у одному обробнику події, це сигнал до рефакторингу у useReducer.Анатомія редюсера: чиста функція переходу стану
Редюсер (reducer) — це чиста функція, яка приймає поточний стан та об'єкт екшену (action), і повертає новий стан. Назва походить від методу масивів Array.prototype.reduce(), який «згортає» масив значень у єдиний результат, застосовуючи функцію до кожного елемента послідовно. Аналогічно редюсер «згортає» історію екшенів у поточний стан застосунку.
Сигнатура редюсера у TypeScript:
type Reducer<S, A> = (state: S, action: A) => S;
Де:
S(State) — тип стану компонента (об'єкт, масив, примітивне значення).A(Action) — тип екшену, зазвичай об'єкт із властивістюtypeта опціональним payload.
useEffect.Перший редюсер: лічильник із трьома операціями
Почнемо з найпростішого прикладу — лічильника, який підтримує три операції: збільшення, зменшення та скидання.
import { useReducer } from "react";
// Типи екшенів через дискримінантне об'єднання
type CounterAction =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" };
// Редюсер: чиста функція переходу стану
function counterReducer(state: number, action: CounterAction): number {
switch (action.type) {
case "increment":
return state + 1;
case "decrement":
return state - 1;
case "reset":
return 0;
default:
// TypeScript гарантує, що ця гілка недосяжна
return state;
}
}
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div className="counter">
<p className="display">Лічильник: {count}</p>
<div className="controls">
<button onClick={() => dispatch({ type: "decrement" })}>–</button>
<button onClick={() => dispatch({ type: "reset" })}>↻</button>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
</div>
</div>
);
}
Збирання React застосунку…
Розбір ключових елементів
Сигнатура хука useReducer. Перш ніж поглиблюватись у деталі, важливо чітко зрозуміти сигнатуру самого хука:
function useReducer<S, A>(
reducer: (state: S, action: A) => S,
initialState: S
): [S, Dispatch<A>];
Де:
S(State) — тип стану вашого компонента.A(Action) — тип екшенів, які може обробляти редюсер.reducer— функція(state: S, action: A) => S, яка описує всі переходи стану.initialState— початкове значення стану.- Повертає: масив із двох елементів
[поточний_стан, функція_dispatch].
Тип функції dispatch. Функція dispatch, яку повертає useReducer, має тип Dispatch<A>:
type Dispatch<A> = (action: A) => void;
Це означає, що dispatch приймає об'єкт типу A (ваш екшен) і нічого не повертає. Виклик dispatch не змінює стан миттєво — він лише планує оновлення для наступного рендеру, як і setState у useState.
dispatch має стабільний референс між рендерами — React гарантує, що це завжди той самий об'єкт. Це робить безпечним передавання dispatch як пропа без необхідності мемоїзації через useCallback.Дискримінантне об'єднання екшенів. TypeScript-тип CounterAction — це об'єднання трьох варіантів екшенів, кожен із яких має унікальне значення type. Властивість type виступає дискримінантом (discriminant) — вона дозволяє TypeScript звузити тип екшену всередині кожної гілки switch. Це забезпечує повну безпеку типів та автодоповнення.
Чистота редюсера. Функція counterReducer не має побічних ефектів і не модифікує аргумент state. Вона повертає нове число, обчислене на основі попереднього стану та типу екшену. Це дозволяє React безпечно мемоїзувати та оптимізувати рендеринг.
Виклик useReducer. Хук приймає два аргументи: функцію редюсера та початкове значення стану. Він повертає масив із двох елементів: поточний стан та функцію dispatch. Функція dispatch приймає екшен-об'єкт і викликає редюсер із поточним станом, після чого React перерендерює компонент із новим станом.
state++; return state; порушує незмінність і призводить до непередбачуваної поведінки React. Завжди повертайте новий об'єкт, масив або примітивне значення.Екшени з payload: передача даних у редюсер
У реальних застосунках екшени рідко бувають порожніми — вони зазвичай несуть дані (payload), необхідні для оновлення стану. Розглянемо список завдань (to-do list), де кожен екшен має додаткові параметри.
import { useReducer, useState } from "react";
interface Task {
id: number;
text: string;
completed: boolean;
}
type TaskAction =
| { type: "added"; text: string }
| { type: "toggled"; id: number }
| { type: "deleted"; id: number };
function tasksReducer(state: Task[], action: TaskAction): Task[] {
switch (action.type) {
case "added":
return [
...state,
{
id: Date.now(),
text: action.text,
completed: false,
},
];
case "toggled":
return state.map((task) =>
task.id === action.id ? { ...task, completed: !task.completed } : task
);
case "deleted":
return state.filter((task) => task.id !== action.id);
default:
return state;
}
}
function TaskList() {
const [tasks, dispatch] = useReducer(tasksReducer, []);
const [inputValue, setInputValue] = useState("");
const handleAdd = () => {
if (inputValue.trim()) {
dispatch({ type: "added", text: inputValue.trim() });
setInputValue("");
}
};
return (
<div className="task-list">
<div className="input-group">
<input
type="text"
placeholder="Нове завдання..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<button onClick={handleAdd}>Додати</button>
</div>
<ul className="tasks">
{tasks.map((task) => (
<li key={task.id} className={task.completed ? "completed" : ""}>
<input
type="checkbox"
checked={task.completed}
onChange={() => dispatch({ type: "toggled", id: task.id })}
/>
<span>{task.text}</span>
<button
className="delete"
onClick={() => dispatch({ type: "deleted", id: task.id })}
>
✕
</button>
</li>
))}
</ul>
{tasks.length === 0 && (
<p className="empty-state">Список завдань порожній</p>
)}
</div>
);
}
Збирання React застосунку…
Розбір ключових паттернів
Payload у дискримінантному об'єднанні. Кожен варіант TaskAction містить додаткові поля: text для створення завдання, id для перемикання стану або видалення. TypeScript автоматично виводить доступні поля після звуження типу через switch (action.type).
Незмінні трансформації масиву. Усі операції зі станом створюють новий масив:
added— оператор розгортання[...state, newTask]додає елемент у кінець.toggled— метод.map()створює новий масив із зміненим одним елементом.deleted— метод.filter()створює новий масив без видаленого елемента.
Локальний стан поля введення. Зверніть увагу: значення інпута (inputValue) не зберігається у редюсері. Це локальний UI-стан, який не потребує централізованого управління. useReducer не замінює useState повністю — вони доповнюють один одного.
useReducer для доменного стану (дані, бізнес-логіка), і залишайте useState для локального UI-стану (відкриті випадаючі списки, значення інпутів до відправки форми, анімаційні прапорці).Складний стан: форма реєстрації з валідацією
Тепер застосуємо useReducer до початкового прикладу з формою реєстрації. Це демонструє основну перевагу редюсера: всі переходи стану зібрані в одному місці, а компонент містить лише декларативні виклики dispatch.
import { useReducer } from "react";
interface RegistrationState {
email: string;
password: string;
confirmPassword: string;
agreedToTerms: boolean;
isSubmitting: boolean;
errors: {
email?: string;
password?: string;
confirmPassword?: string;
};
}
type RegistrationAction =
| { type: "field_changed"; field: "email" | "password" | "confirmPassword"; value: string }
| { type: "terms_toggled" }
| { type: "submit_started" }
| { type: "validation_failed"; errors: RegistrationState["errors"] }
| { type: "submit_succeeded" }
| { type: "submit_failed" };
const initialState: RegistrationState = {
email: "",
password: "",
confirmPassword: "",
agreedToTerms: false,
isSubmitting: false,
errors: {},
};
function registrationReducer(
state: RegistrationState,
action: RegistrationAction
): RegistrationState {
switch (action.type) {
case "field_changed":
return {
...state,
[action.field]: action.value,
// Очищення помилки при редагуванні поля
errors: {
...state.errors,
[action.field]: undefined,
},
};
case "terms_toggled":
return {
...state,
agreedToTerms: !state.agreedToTerms,
};
case "submit_started":
return {
...state,
isSubmitting: true,
errors: {},
};
case "validation_failed":
return {
...state,
isSubmitting: false,
errors: action.errors,
};
case "submit_succeeded":
return initialState; // Скидання форми
case "submit_failed":
return {
...state,
isSubmitting: false,
};
default:
return state;
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(registrationReducer, initialState);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: "submit_started" });
// Валідація
const errors: RegistrationState["errors"] = {};
if (!state.email.includes("@")) {
errors.email = "Невірний формат email";
}
if (state.password.length < 8) {
errors.password = "Пароль має містити мінімум 8 символів";
}
if (state.password !== state.confirmPassword) {
errors.confirmPassword = "Паролі не збігаються";
}
if (Object.keys(errors).length > 0) {
dispatch({ type: "validation_failed", errors });
return;
}
try {
// Імітація мережевого запиту
await new Promise((resolve) => setTimeout(resolve, 1500));
dispatch({ type: "submit_succeeded" });
alert("Реєстрація успішна!");
} catch {
dispatch({ type: "submit_failed" });
alert("Помилка відправки форми");
}
};
return (
<form className="registration-form" onSubmit={handleSubmit}>
<h2>Реєстрація</h2>
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
type="text"
value={state.email}
onChange={(e) =>
dispatch({
type: "field_changed",
field: "email",
value: e.target.value,
})
}
className={state.errors.email ? "error" : ""}
/>
{state.errors.email && (
<span className="error-message">{state.errors.email}</span>
)}
</div>
<div className="field">
<label htmlFor="password">Пароль</label>
<input
id="password"
type="password"
value={state.password}
onChange={(e) =>
dispatch({
type: "field_changed",
field: "password",
value: e.target.value,
})
}
className={state.errors.password ? "error" : ""}
/>
{state.errors.password && (
<span className="error-message">{state.errors.password}</span>
)}
</div>
<div className="field">
<label htmlFor="confirmPassword">Підтвердження пароля</label>
<input
id="confirmPassword"
type="password"
value={state.confirmPassword}
onChange={(e) =>
dispatch({
type: "field_changed",
field: "confirmPassword",
value: e.target.value,
})
}
className={state.errors.confirmPassword ? "error" : ""}
/>
{state.errors.confirmPassword && (
<span className="error-message">{state.errors.confirmPassword}</span>
)}
</div>
<div className="checkbox-field">
<input
id="terms"
type="checkbox"
checked={state.agreedToTerms}
onChange={() => dispatch({ type: "terms_toggled" })}
/>
<label htmlFor="terms">Я погоджуюсь із умовами використання</label>
</div>
<button
type="submit"
disabled={!state.agreedToTerms || state.isSubmitting}
>
{state.isSubmitting ? "Відправка..." : "Зареєструватись"}
</button>
</form>
);
}
Збирання React застосунку…
Переваги централізованої логіки
Порівняйте цю реалізацію з початковим прикладом на useState. Тепер:
- Вся логіка стану в одному місці. Щоб зрозуміти, як змінюється стан форми, достатньо прочитати функцію
registrationReducer. Не треба збирати фрагменти логіки з різних обробників подій. - Декларативні наміри. Виклик
dispatch({ type: "submit_started" })чітко описує намір, а не механіку реалізації. Це робить код самодокументованим. - Простота тестування. Редюсер — це чиста функція, яку можна тестувати ізольовано без монтування компонента:
import { describe, test, expect } from "vitest";
describe("registrationReducer", () => {
test("очищує помилку при редагуванні поля", () => {
const state: RegistrationState = {
...initialState,
errors: { email: "Невірний формат email" },
};
const newState = registrationReducer(state, {
type: "field_changed",
field: "email",
value: "test@example.com",
});
expect(newState.email).toBe("test@example.com");
expect(newState.errors.email).toBeUndefined();
});
test("встановлює прапорець завантаження при початку відправки", () => {
const newState = registrationReducer(initialState, {
type: "submit_started",
});
expect(newState.isSubmitting).toBe(true);
expect(newState.errors).toEqual({});
});
});
- Масштабованість. Додавання нового поля чи валідаційного правила зводиться до розширення типу
RegistrationActionта нової гілки уswitch.
src/reducers/registrationReducer.ts.Ініціалізація стану: lazy initialization
Іноді початковий стан має обчислюватися на основі пропсів або потребує складних трансформацій. Для цього useReducer підтримує третій параметр — функцію ініціалізації (initializer function).
interface CartState {
items: Array<{ id: number; name: string; quantity: number }>;
total: number;
}
type CartAction =
| { type: "item_added"; item: { id: number; name: string; price: number } }
| { type: "item_removed"; id: number }
| { type: "cart_cleared" };
// Функція ініціалізації: завантажує стан із localStorage
function initCartState(): CartState {
const saved = localStorage.getItem("cart");
if (saved) {
try {
return JSON.parse(saved);
} catch {
// Якщо дані пошкоджені, повертаємо порожній кошик
}
}
return { items: [], total: 0 };
}
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "item_added":
const newItems = [...state.items, { ...action.item, quantity: 1 }];
return { items: newItems, total: state.total + action.item.price };
case "item_removed":
const filtered = state.items.filter((item) => item.id !== action.id);
return { items: filtered, total: calculateTotal(filtered) };
case "cart_cleared":
return { items: [], total: 0 };
default:
return state;
}
}
function ShoppingCart() {
// Третій аргумент — функція ініціалізації, викликається лише один раз
const [cart, dispatch] = useReducer(
cartReducer,
undefined,
initCartState
);
// Зберігаємо стан у localStorage при кожній зміні
useEffect(() => {
localStorage.setItem("cart", JSON.stringify(cart));
}, [cart]);
// Решта коду компонента...
}
Чому не просто useReducer(cartReducer, initCartState())?
Якщо передати результат виклику initCartState() як другий аргумент, функція виконається при кожному рендері компонента, навіть коли стан уже ініціалізовано. Це марна трата ресурсів, особливо якщо ініціалізація включає парсинг великих JSON-структур або обчислення.
Передаючи функцію як третій аргумент, ви гарантуєте, що вона виконається лише один раз при монтуванні компонента. Це паттерн відомий як ледача ініціалізація (lazy initialization).
- Завантажується з
localStorageабоsessionStorage. - Обчислюється на основі складних трансформацій пропсів.
- Парсить дані з URL (query-параметри, хеш-фрагменти).
- Залежить від асинхронних операцій (хоча для асинхронних даних краще підходить комбінація
useState+useEffect).
useState vs useReducer: коли використовувати кожен
Обидва хуки призначені для управління станом, але мають різні зони застосування. Ось систематичне порівняння:
| Критерій | useState | useReducer |
|---|---|---|
| Складність стану | Прості примітиви або плоскі об'єкти | Глибоко вкладені об'єкти, зв'язані поля |
| Логіка оновлень | Проста зміна значення | Складні переходи, множинні залежності |
| Розподіл логіки | Розсіяна по обробниках подій | Централізована в редюсері |
| Тестування | Потребує монтування компонента | Редюсер тестується ізольовано |
| Читабельність | Зрозуміліша для простих випадків | Зрозуміліша для складних автоматів станів |
| Передача вниз | Передача setter-функцій | Передача dispatch + екшен-криейтори |
| Довгострокова підтримка | Може розростатись хаотично | Масштабується передбачувано |
useReducer скрізь автоматично. Для простих випадків (прапорець, лічильник, одне текстове поле) useState є кращим вибором — він прозоріший та вимагає менше коду. Редюсер виправданий, коли логіка стану дійсно складна.Типові сигнали для переходу на useReducer:
- Множинні
setStateу одному обробнику події. Якщо один клік користувача змінює 3-4 незалежні частини стану, це кандидат на об'єднання у єдиний екшен. - Дублювання логіки між обробниками.
Коли кілька обробників містять ідентичні фрагменти коду (очищення помилок, скидання прапорців), редюсер дозволяє винести цю логіку у гілки
switch. - Складна валідація або взаємозалежні поля. Форми, де зміна одного поля впливає на доступність або валідацію інших полів, ідеально моделюються через редюсери.
- Необхідність повторного відтворення послідовності дій. Для налагодження або логування корисно мати список екшенів, що призвели до поточного стану. Редюсер робить це природно.
Організація екшенів: від constans до action creators
У міру зростання застосунку кількість різних екшенів може досягати десятків, і виникає потреба у структурованому підході до їхнього визначення. Розглянемо еволюцію патернів організації екшенів — від найпростішого до найбільш масштабованого.
Етап 1: Інлайнові рядкові літерали
На початку, коли екшенів небагато, їх можна створювати безпосередньо у виклику dispatch:
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div>
<button onClick={() => dispatch({ type: "increment" })}>+1</button>
<button onClick={() => dispatch({ type: "decrement" })}>-1</button>
</div>
);
}
Проблеми цього підходу:
- Легко зробити друкарську помилку:
{ type: "incremen" }замість"increment". - При рефакторингу доводиться шукати всі місця використання вручну.
- Немає централізованого списку всіх можливих екшенів.
Етап 2: Action Type Constants (константи типів екшенів)
Перший крок до структурованості — винести рядкові літерали у константи:
// constants.ts
export const INCREMENT = "INCREMENT" as const;
export const DECREMENT = "DECREMENT" as const;
export const RESET = "RESET" as const;
// types.ts
import { INCREMENT, DECREMENT, RESET } from "./constants";
type CounterAction =
| { type: typeof INCREMENT }
| { type: typeof DECREMENT }
| { type: typeof RESET };
// Counter.tsx
import { INCREMENT, DECREMENT, RESET } from "./constants";
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div>
<button onClick={() => dispatch({ type: INCREMENT })}>+1</button>
<button onClick={() => dispatch({ type: DECREMENT })}>-1</button>
<button onClick={() => dispatch({ type: RESET })}>↻</button>
</div>
);
}
Переваги:
- Захист від друкарських помилок: константа
INCREMENTперевіряється TypeScript. - Рефакторинг стає простішим: зміна значення константи оновлює всі використання.
- Централізований список усіх типів екшенів.
Недоліки:
- Все ще потрібно вручну створювати об'єкти екшенів
{ type: INCREMENT, payload: ... }. - Для екшенів із payload треба пам'ятати структуру кожного екшену.
as const перетворює рядок у літеральний тип TypeScript. Без неї TypeScript виводить тип string, а не конкретне значення "INCREMENT", що руйнує можливість звуження типу у редюсері.Етап 3: Прості Action Creator функції
Наступний крок — інкапсулювати створення екшен-об'єктів у функції:
// actions.ts
export const INCREMENT = "INCREMENT" as const;
export const DECREMENT = "DECREMENT" as const;
export const INCREMENT_BY = "INCREMENT_BY" as const;
// Action creator функції
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
export const incrementBy = (amount: number) => ({
type: INCREMENT_BY,
payload: amount,
});
// Виведення типу всіх екшенів
export type CounterAction =
| ReturnType<typeof increment>
| ReturnType<typeof decrement>
| ReturnType<typeof incrementBy>;
// Counter.tsx
import { useReducer } from "react";
import { increment, decrement, incrementBy } from "./actions";
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(incrementBy(5))}>+5</button>
</div>
);
}
Переваги:
- Автодоповнення параметрів: IDE підказує, що
incrementByприймаєamount: number. - Інкапсуляція структури: якщо потрібно додати поле до екшену, змінюємо лише action creator.
- Читабельність:
dispatch(incrementBy(5))природніше, ніжdispatch({ type: INCREMENT_BY, payload: 5 }).
Недоліки:
- Доводиться вручну перераховувати всі
ReturnTypeу типіCounterAction. - Константи типів і action creators розкидані по коду.
Етап 4: Об'єкт Action Creators (масштабований підхід)
Для великих застосунків зручно групувати всі action creators в один об'єкт:
// actions.ts — Централізоване визначення всіх екшенів
export const taskActions = {
added: (text: string) => ({ type: "added" as const, text }),
toggled: (id: number) => ({ type: "toggled" as const, id }),
deleted: (id: number) => ({ type: "deleted" as const, id }),
edited: (id: number, newText: string) => ({
type: "edited" as const,
id,
newText,
}),
};
// Автоматичне виведення типу всіх екшенів
export type TaskAction = ReturnType<
(typeof taskActions)[keyof typeof taskActions]
>;
// TaskList.tsx
import { useReducer } from "react";
import { taskActions, TaskAction } from "./actions";
interface Task {
id: number;
text: string;
completed: boolean;
}
function tasksReducer(state: Task[], action: TaskAction): Task[] {
switch (action.type) {
case "added":
return [
...state,
{ id: Date.now(), text: action.text, completed: false },
];
case "toggled":
return state.map((task) =>
task.id === action.id ? { ...task, completed: !task.completed } : task
);
case "deleted":
return state.filter((task) => task.id !== action.id);
case "edited":
return state.map((task) =>
task.id === action.id ? { ...task, text: action.newText } : task
);
default:
return state;
}
}
function TaskList() {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<div>
<button onClick={() => dispatch(taskActions.added("Нове завдання"))}>
Додати
</button>
{tasks.map((task) => (
<div key={task.id}>
<span>{task.text}</span>
<button onClick={() => dispatch(taskActions.toggled(task.id))}>
Перемкнути
</button>
<button onClick={() => dispatch(taskActions.deleted(task.id))}>
Видалити
</button>
</div>
))}
</div>
);
}
Розбір TypeScript-магії:
type TaskAction = ReturnType<
(typeof taskActions)[keyof typeof taskActions]
>;
Ця конструкція працює наступним чином:
typeof taskActions— отримуємо тип об'єкта:{ added: (text: string) => { type: "added"; text: string }; toggled: (id: number) => { type: "toggled"; id: number }; // ... }keyof typeof taskActions— отримуємо об'єднання ключів:"added" | "toggled" | "deleted" | "edited".(typeof taskActions)[keyof typeof taskActions]— індексація об'єкта всіма ключами одночасно дає об'єднання всіх типів функцій.ReturnType<...>— витягуємо типи повернення всіх функцій, отримуючи фінальне об'єднання екшенів.
Переваги цього підходу:
- Автоматичне виведення типів. Додавання нового action creator автоматично додає його до типу
TaskAction— не треба вручну оновлювати об'єднання типів. - Автодоповнення. IDE пропонує всі доступні методи
taskActions.*із сигнатурами параметрів. - Захист від помилок. Неможливо випадково створити екшен з неправильним типом або структурою.
- Централізований рефакторинг. Зміна структури екшену потребує правки лише в одному місці.
- Читабельність.
taskActions.added(text)природніше, ніж{ type: "added", text }.
createSlice генерує саме такий об'єкт action creators автоматично).Порівняльна таблиця підходів
| Підхід | Захист від помилок | Автодоповнення | Рефакторинг | Складність | Рекомендовано для |
|---|---|---|---|---|---|
| Інлайнові літерали | ❌ | ❌ | ❌ | Низька | Прототипи, навчання |
| Action Constants | ✅ | Часткове | ✅ | Низька | Малі проєкти (< 10 екшенів) |
| Прості функції | ✅ | ✅ | ✅ | Середня | Середні проєкти (10-30 екшенів) |
| Об'єкт creators | ✅ | ✅ | ✅ | Середня | Великі проєкти (30+ екшенів) |
Практичні завдання
Розширте базовий лічильник, додавши можливість змінювати значення на довільний крок (step). Додайте екшени:
increment_by— збільшення на вказане число.decrement_by— зменшення на вказане число.set_step— зміна значення кроку.
Інтерфейс має містити інпут для встановлення кроку та кнопки для інкременту/декременту.
Вимоги:
- Початковий крок:
1. - Крок має бути додатним цілим числом (валідація в редюсері).
- Типізація через
discriminated union.
import { useReducer, useState } from "react";
interface CounterState {
value: number;
step: number;
}
type CounterAction =
| { type: "increment_by"; amount: number }
| { type: "decrement_by"; amount: number }
| { type: "set_step"; step: number }
| { type: "reset" };
function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
switch (action.type) {
case "increment_by":
return { ...state, value: state.value + action.amount };
case "decrement_by":
return { ...state, value: state.value - action.amount };
case "set_step":
// Валідація: крок має бути додатним
const validStep = Math.max(1, Math.floor(action.step));
return { ...state, step: validStep };
case "reset":
return { value: 0, step: 1 };
default:
return state;
}
}
function StepCounter() {
const [state, dispatch] = useReducer(counterReducer, {
value: 0,
step: 1,
});
const [stepInput, setStepInput] = useState("1");
const handleStepChange = () => {
const parsed = parseInt(stepInput, 10);
if (!isNaN(parsed)) {
dispatch({ type: "set_step", step: parsed });
}
};
return (
<div className="step-counter">
<p className="display">Значення: {state.value}</p>
<div className="step-controls">
<label>
Крок:
<input
type="number"
min="1"
value={stepInput}
onChange={(e) => setStepInput(e.target.value)}
onBlur={handleStepChange}
/>
</label>
<span>Поточний крок: {state.step}</span>
</div>
<div className="controls">
<button
onClick={() =>
dispatch({ type: "decrement_by", amount: state.step })
}
>
−{state.step}
</button>
<button onClick={() => dispatch({ type: "reset" })}>Скинути</button>
<button
onClick={() =>
dispatch({ type: "increment_by", amount: state.step })
}
>
+{state.step}
</button>
</div>
</div>
);
}
Покроковий аналіз:
- Структура стану: Об'єкт
CounterStateмістить як поточне значення, так і крок. Це природніше, ніж окреміuseStateдля кожного поля. - Валідація кроку в редюсері: Гілка
set_stepзастосовуєMath.max(1, ...)таMath.floor(), гарантуючи, що крок завжди додатне ціле число. Це приклад того, як редюсер може містити бізнес-логіку, а не лише трансформації даних. - Локальний стан інпута: Значення
stepInputзберігається уuseState, а не в редюсері, оскільки це тимчасовий UI-стан до моменту підтвердження (blur event).
Розширте список завдань, додавши фільтрацію за статусом. Додайте екшен:
filter_changed— зміна активного фільтра.
Варіанти фільтрів:
all— показати всі завдання.active— лише незавершені.completed— лише завершені.
Інтерфейс має містити три кнопки фільтрів над списком. Активний фільтр має візуально виділятися.
import { useReducer, useState } from "react";
interface Task {
id: number;
text: string;
completed: boolean;
}
type Filter = "all" | "active" | "completed";
interface TasksState {
tasks: Task[];
filter: Filter;
}
type TaskAction =
| { type: "added"; text: string }
| { type: "toggled"; id: number }
| { type: "deleted"; id: number }
| { type: "filter_changed"; filter: Filter };
function tasksReducer(state: TasksState, action: TaskAction): TasksState {
switch (action.type) {
case "added":
return {
...state,
tasks: [
...state.tasks,
{ id: Date.now(), text: action.text, completed: false },
],
};
case "toggled":
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task
),
};
case "deleted":
return {
...state,
tasks: state.tasks.filter((task) => task.id !== action.id),
};
case "filter_changed":
return {
...state,
filter: action.filter,
};
default:
return state;
}
}
function FilteredTaskList() {
const [state, dispatch] = useReducer(tasksReducer, {
tasks: [],
filter: "all",
});
const [inputValue, setInputValue] = useState("");
// Обчислення відфільтрованих завдань
const filteredTasks = state.tasks.filter((task) => {
if (state.filter === "active") return !task.completed;
if (state.filter === "completed") return task.completed;
return true;
});
const handleAdd = () => {
if (inputValue.trim()) {
dispatch({ type: "added", text: inputValue.trim() });
setInputValue("");
}
};
return (
<div className="filtered-task-list">
<div className="input-group">
<input
type="text"
placeholder="Нове завдання..."
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<button onClick={handleAdd}>Додати</button>
</div>
<div className="filters">
{(["all", "active", "completed"] as const).map((filter) => (
<button
key={filter}
className={state.filter === filter ? "active" : ""}
onClick={() => dispatch({ type: "filter_changed", filter })}
>
{filter === "all" && "Усі"}
{filter === "active" && "Активні"}
{filter === "completed" && "Завершені"}
</button>
))}
</div>
<ul className="tasks">
{filteredTasks.map((task) => (
<li key={task.id} className={task.completed ? "completed" : ""}>
<input
type="checkbox"
checked={task.completed}
onChange={() => dispatch({ type: "toggled", id: task.id })}
/>
<span>{task.text}</span>
<button onClick={() => dispatch({ type: "deleted", id: task.id })}>
✕
</button>
</li>
))}
</ul>
{filteredTasks.length === 0 && (
<p className="empty-state">
{state.filter === "all" && "Список завдань порожній"}
{state.filter === "active" && "Немає активних завдань"}
{state.filter === "completed" && "Немає завершених завдань"}
</p>
)}
</div>
);
}
Покроковий аналіз:
- Розширення стану: До
tasksдодано полеfilter. Тепер редюсер управляє як даними, так і UI-станом фільтрації. - Обчислювана логіка фільтрації: Функція компонента обчислює
filteredTasksна основі поточного фільтра. Це приклад того, що не всі дані мають зберігатися у стані — обчислювані значення краще виводити безпосередньо при рендері. - Візуальна індикація: Активна кнопка фільтра отримує клас
active, що дозволяє користувачу бачити поточний режим перегляду.
Створіть багатокрокову форму реєстрації (wizard) з трьома кроками:
- Особисті дані: ім'я, прізвище.
- Контактна інформація: email, телефон.
- Підтвердження: перегляд усіх даних та відправка.
Вимоги:
- Навігація вперед/назад між кроками.
- Валідація на кожному кроці (неможливо перейти далі з порожніми полями).
- Візуальний індикатор поточного кроку (прогрес-бар або нумерація).
- Екшени:
next_step,previous_step,field_changed,submit.
import { useReducer } from "react";
interface WizardState {
currentStep: number;
data: {
firstName: string;
lastName: string;
email: string;
phone: string;
};
errors: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
};
}
type WizardAction =
| { type: "next_step" }
| { type: "previous_step" }
| {
type: "field_changed";
field: keyof WizardState["data"];
value: string;
}
| { type: "validation_failed"; errors: WizardState["errors"] }
| { type: "submit" };
const initialState: WizardState = {
currentStep: 1,
data: {
firstName: "",
lastName: "",
email: "",
phone: "",
},
errors: {},
};
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case "next_step":
return {
...state,
currentStep: Math.min(3, state.currentStep + 1),
errors: {},
};
case "previous_step":
return {
...state,
currentStep: Math.max(1, state.currentStep - 1),
errors: {},
};
case "field_changed":
return {
...state,
data: {
...state.data,
[action.field]: action.value,
},
errors: {
...state.errors,
[action.field]: undefined,
},
};
case "validation_failed":
return {
...state,
errors: action.errors,
};
case "submit":
// Логіка відправки форми
console.log("Відправка даних:", state.data);
return initialState; // Скидання після успішної відправки
default:
return state;
}
}
function RegistrationWizard() {
const [state, dispatch] = useReducer(wizardReducer, initialState);
const validateStep = (): boolean => {
const errors: WizardState["errors"] = {};
if (state.currentStep === 1) {
if (!state.data.firstName.trim())
errors.firstName = "Введіть ім'я";
if (!state.data.lastName.trim())
errors.lastName = "Введіть прізвище";
}
if (state.currentStep === 2) {
if (!state.data.email.includes("@"))
errors.email = "Невірний формат email";
if (!/^\+?\d{10,}$/.test(state.data.phone))
errors.phone = "Невірний формат телефону";
}
if (Object.keys(errors).length > 0) {
dispatch({ type: "validation_failed", errors });
return false;
}
return true;
};
const handleNext = () => {
if (validateStep()) {
dispatch({ type: "next_step" });
}
};
const handleSubmit = () => {
if (validateStep()) {
dispatch({ type: "submit" });
alert("Реєстрація успішна!");
}
};
return (
<div className="wizard">
<div className="progress">
<div className="step-indicator">
<span className={state.currentStep >= 1 ? "active" : ""}>1</span>
<span className={state.currentStep >= 2 ? "active" : ""}>2</span>
<span className={state.currentStep >= 3 ? "active" : ""}>3</span>
</div>
</div>
<div className="wizard-content">
{state.currentStep === 1 && (
<div className="step">
<h2>Крок 1: Особисті дані</h2>
<input
type="text"
placeholder="Ім'я"
value={state.data.firstName}
onChange={(e) =>
dispatch({
type: "field_changed",
field: "firstName",
value: e.target.value,
})
}
/>
{state.errors.firstName && (
<span className="error">{state.errors.firstName}</span>
)}
<input
type="text"
placeholder="Прізвище"
value={state.data.lastName}
onChange={(e) =>
dispatch({
type: "field_changed",
field: "lastName",
value: e.target.value,
})
}
/>
{state.errors.lastName && (
<span className="error">{state.errors.lastName}</span>
)}
</div>
)}
{state.currentStep === 2 && (
<div className="step">
<h2>Крок 2: Контактна інформація</h2>
<input
type="email"
placeholder="Email"
value={state.data.email}
onChange={(e) =>
dispatch({
type: "field_changed",
field: "email",
value: e.target.value,
})
}
/>
{state.errors.email && (
<span className="error">{state.errors.email}</span>
)}
<input
type="tel"
placeholder="Телефон"
value={state.data.phone}
onChange={(e) =>
dispatch({
type: "field_changed",
field: "phone",
value: e.target.value,
})
}
/>
{state.errors.phone && (
<span className="error">{state.errors.phone}</span>
)}
</div>
)}
{state.currentStep === 3 && (
<div className="step">
<h2>Крок 3: Підтвердження</h2>
<div className="summary">
<p>
<strong>Ім'я:</strong> {state.data.firstName}
</p>
<p>
<strong>Прізвище:</strong> {state.data.lastName}
</p>
<p>
<strong>Email:</strong> {state.data.email}
</p>
<p>
<strong>Телефон:</strong> {state.data.phone}
</p>
</div>
</div>
)}
</div>
<div className="wizard-controls">
{state.currentStep > 1 && (
<button onClick={() => dispatch({ type: "previous_step" })}>
Назад
</button>
)}
{state.currentStep < 3 && (
<button onClick={handleNext}>Далі</button>
)}
{state.currentStep === 3 && (
<button onClick={handleSubmit}>Зареєструватись</button>
)}
</div>
</div>
);
}
Покроковий аналіз:
- Крок як частина стану: Поле
currentStepконтролює, який блок форми відображається. Це класичний приклад машини станів (state machine) — кожен крок є окремим станом із власними переходами. - Валідація на кожному кроці: Функція
validateStepперевіряє лише поля поточного кроку. Це дозволяє користувачу отримувати негайний фідбек, не чекаючи кінця всієї форми. - Умовна навігація: Кнопка «Назад» з'являється лише після першого кроку, а кнопка «Зареєструватись» — лише на останньому.
Запитання для самоконтролю
Чистота редюсера критична з трьох причин:
- Передбачуваність та відтворюваність. Чиста функція завжди повертає однаковий результат для однакових аргументів. Це дозволяє React безпечно викликати редюсер кілька разів (наприклад, у Strict Mode або під час конкурентного рендерингу) без зміни кінцевого результату.
- Можливість оптимізації. React може мемоїзувати результати рендерингу та пропускати повторні виклики, якщо пропси та стан не змінилися. Побічні ефекти у редюсері порушують цю гарантію — кожен виклик може мати різний результат навіть із однаковими вхідними даними.
- Тестування та налагодження. Чисті функції легко тестувати ізольовано без моків, стабів чи складного налаштування. Для перевірки редюсера достатньо передати стан та екшен і порівняти результат.
dispatch, а не всередині редюсера.Так, абсолютно. Якщо компонент управляє кількома незалежними доменами стану (наприклад, форма користувача та стан модального вікна), логічно розділити їх на окремі редюсери:
function ComplexComponent() {
const [formState, formDispatch] = useReducer(formReducer, initialFormState);
const [modalState, modalDispatch] = useReducer(modalReducer, { isOpen: false });
const [notificationState, notificationDispatch] = useReducer(
notificationReducer,
{ messages: [] }
);
// Кожен редюсер управляє своєю логікою
}
Це краще, ніж один величезний редюсер із гілками switch, що охоплюють різні аспекти UI. Розділення на менші редюсери покращує модульність та тестованість.
Обидві функції плануют оновлення стану, але вони принципово відрізняються за підходом:
setState (від useState):
- Імперативний підхід: «Встанови значення
X». - Безпосередньо змінює конкретну частину стану.
- Не містить семантики «що сталося» — лише «яке нове значення».
dispatch (від useReducer):
- Декларативний підхід: «Сталася подія типу
Yз данимиZ». - Передає управління редюсеру, який вирішує, як змінити стан.
- Містить семантичний опис дії користувача або системи.
Приклад різниці:
// useState: імперативно
setCount(count + 1);
setCount(count + 1);
// Обидва виклики виконаються послідовно, але якщо count не оновиться
// між викликами через батчинг, результат буде неправильним.
// useReducer: декларативно
dispatch({ type: "increment" });
dispatch({ type: "increment" });
// Кожен виклик незалежний, редюсер отримує актуальний стан.
setState може призвести до гонки станів (race conditions), якщо кілька оновлень відбуваються одночасно. Редюсер цього уникає, оскільки кожен екшен обробляється послідовно з актуальним станом.Перехід виправданий, коли виникає хоча б одна з цих ситуацій:
- Кілька пов'язаних змінних стану. Якщо зміна однієї змінної завжди супроводжується змінами інших, об'єднайте їх у єдиний об'єкт стану та управляйте через редюсер.
- Складна логіка оновлення.
Якщо виклик
setStateмістить вкладені умови, перетворення масивів або об'єктів, винесіть цю логіку в редюсер. - Повторювана логіка між обробниками. Якщо кілька обробників подій містять ідентичні фрагменти коду, це сигнал до централізації.
- Необхідність тестування логіки стану окремо. Редюсер можна тестувати як чисту функцію без монтування компонента.
- Налагодження складних переходів станів. Якщо важко відстежити, чому стан змінився так, а не інакше, екшени дають чітку історію подій.
Антипатерн: Застосовувати useReducer для простих булевих прапорців або лічильників. У таких випадках useState читабельніший і лаконічніший.
Так, і це один із найбільш потужних патернів useReducer. Функція dispatch стабільна між рендерами (React гарантує, що це завжди той самий референс), тому її можна безпечно передавати як проп без побоювань щодо непотрібних перерендерів.
function ParentComponent() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<ChildA dispatch={dispatch} />
<ChildB dispatch={dispatch} />
</div>
);
}
function ChildA({ dispatch }: { dispatch: React.Dispatch<Action> }) {
return (
<button onClick={() => dispatch({ type: "increment" })}>
Інкремент
</button>
);
}
Це особливо корисно в поєднанні з Context API — dispatch розміщується у контексті, і будь-який компонент може надсилати екшени без прокидання пропсів через всі проміжні рівні.
dispatch краще за передачу окремих callback-функцій, оскільки:- Дочірні компоненти не залежать від конкретної реалізації логіки.
- Легко додавати нові екшени без зміни сигнатур пропсів.
- Дочірні компоненти описують намір (
dispatch({ type: "save" })), а не деталі реалізації (handleSave()).
Підсумок
Хук useReducer є потужним інструментом для управління складним станом у React-застосунках. Ключові висновки цієї лекції:
📦 Централізація логіки
🎯 Декларативність
dispatch описують що сталося (намір користувача), а не як змінити стан (механіку реалізації). Це робить код самодокументованим.🔒 Типобезпека
⚡ Масштабованість
switch.Вибір між useState та useReducer
Остаточне правило дуже просте: починайте з useState, і переходьте до useReducer, коли відчуєте, що логіка стану виходить з-під контролю. useReducer — це не заміна useState, а інструмент для випадків, коли складність вимагає структурованого підходу.
useReducer із Context API для побудови глобального сховища стану без сторонніх бібліотек, а також як інтегрувати редюсери з асинхронною логікою через middleware-патерни.Додаткові ресурси
createSlice автоматично генерує екшени та редюсери з TypeScript-типізацією. Корисно вивчити для розуміння масштабованих патернів.state.count++ замість { ...state, count: state.count + 1 }. React Redux та Redux Toolkit використовують Immer під капотом.JSX — розмітка всередині TypeScript
Практичне використання TypeScript-виразів у JSX — фігурні дужки як вікно у TypeScript, динамічне формування атрибутів, обчислювані стилі, умовний рендеринг через тернарні оператори та логічні вирази, робота з масивами та об'єктами у розмітці.
Context API: передавання даних без «пропс-дрілінгу»
Глибоке занурення в Context API для передавання даних через дерево компонентів без прокидання пропсів. Від простих тем до складних систем глобального стану.