Пошук уроків, статей та іншого контенту
Побудуєте складну логіку стану за допомогою useReducer, reducer-функцій, дій і початкового стану.
useReduceruseReducer — це React-хук для керування станом, коли:
стан має кілька пов’язаних властивостей;
зміна стану залежить від типу події;
одна дія змінює кілька частин стану;
логіку оновлення стану потрібно винести окремо від компонента.
Для простого лічильника достатньо useState:
const [count, setCount] = useState(0);Але коли стан стає складнішим, багато викликів setState можуть розподілити логіку по всьому компоненту. useReducer збирає цю логіку в одну функцію — reducer.
useReducerReducer-архітектура складається з чотирьох частин:
Початковий стан — об’єкт або інше значення, з якого починається робота компонента.
Reducer — функція, яка отримує поточний стан і дію та повертає новий стан.
Дія — звичайний об’єкт, що описує, що сталося.
dispatch — функція для надсилання дій reducer-функції.
Базовий виклик має такий вигляд:
const [state, dispatch] = useReducer(reducer, initialState);Reducer має такий контракт:
function reducer(state, action) {
// Повертаємо новий стан
}Виклик dispatch:
dispatch({ type: 'increment' });React передає цю дію до reducer:
const newState = reducer(state, { type: 'increment' });Після цього React повторно відрендерить компонент із новим станом.
Розглянемо стан списку завдань:
const initialState = {
tasks: [],
filter: 'all'
};У цьому стані є:
масив завдань;
поточний фільтр.
Опишемо дії, які можуть відбуватися:
додавання завдання;
перемикання статусу виконання;
видалення завдання;
зміна фільтра;
видалення всіх виконаних завдань.
Reducer може мати такий вигляд:
function tasksReducer(state, action) {
switch (action.type) {
case 'taskAdded':
return {
...state,
tasks: [
...state.tasks,
{
id: action.id,
title: action.title,
completed: false
}
]
};
case 'taskToggled':
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task
)
};
case 'taskDeleted':
return {
...state,
tasks: state.tasks.filter((task) => task.id !== action.id)
};
case 'filterChanged':
return {
...state,
filter: action.filter
};
case 'completedTasksCleared':
return {
...state,
tasks: state.tasks.filter((task) => !task.completed)
};
default:
throw new Error(`Невідома дія: ${action.type}`);
}
}Кожен case:
перевіряє тип дії;
обчислює новий стан;
повертає цей стан.
Дія зазвичай має властивість type. Додаткові дані передаються в інших властивостях, наприклад id, title або filter.
dispatch({
type: 'taskAdded',
id: 1,
title: 'Вивчити useReducer'
});У reducer ці значення доступні через action:
case 'taskAdded':
return {
...state,
tasks: [
...state.tasks,
{
id: action.id,
title: action.title,
completed: false
}
]
};Назви типів дій не мають бути саме такими. Важливо, щоб вони були зрозумілими та однозначними. Часто використовують назви в минулому часі, які описують подію:
taskAdded;
taskToggled;
filterChanged.
Такі назви підкреслюють, що дія описує подію, а не безпосередню команду зміни стану.
У наступному прикладі:
useReducer зберігає завдання та активний фільтр;
useState зберігає текст нового завдання;
reducer не залежить від React;
усі зміни масиву виконуються без мутації.
import { useReducer, useState } from 'react';
const initialState = {
tasks: [
{
id: 1,
title: 'Повторити базові хуки React',
completed: true
},
{
id: 2,
title: 'Вивчити useReducer',
completed: false
}
],
filter: 'all'
};
function tasksReducer(state, action) {
switch (action.type) {
case 'taskAdded':
return {
...state,
tasks: [
...state.tasks,
{
id: action.id,
title: action.title,
completed: false
}
]
};
case 'taskToggled':
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task
)
};
case 'taskDeleted':
return {
...state,
tasks: state.tasks.filter((task) => task.id !== action.id)
};
case 'filterChanged':
return {
...state,
filter: action.filter
};
case 'completedTasksCleared':
return {
...state,
tasks: state.tasks.filter((task) => !task.completed)
};
default:
throw new Error(`Невідома дія: ${action.type}`);
}
}
function getVisibleTasks(tasks, filter) {
switch (filter) {
case 'active':
return tasks.filter((task) => !task.completed);
case 'completed':
return tasks.filter((task) => task.completed);
default:
return tasks;
}
}
export default function TaskList() {
const [state, dispatch] = useReducer(tasksReducer, initialState);
const [newTaskTitle, setNewTaskTitle] = useState('');
const visibleTasks = getVisibleTasks(state.tasks, state.filter);
const activeTasksCount = state.tasks.filter(
(task) => !task.completed
).length;
function handleSubmit(event) {
event.preventDefault();
const title = newTaskTitle.trim();
if (!title) {
return;
}
dispatch({
type: 'taskAdded',
id: Date.now(),
title
});
setNewTaskTitle('');
}
return (
<main>
<h1>Список завдань</h1>
<form onSubmit={handleSubmit}>
<label>
Нове завдання
<input
value={newTaskTitle}
onChange={(event) => setNewTaskTitle(event.target.value)}
placeholder="Наприклад, прочитати документацію"
/>
</label>
<button type="submit">Додати</button>
</form>
<p>Невиконаних завдань: {activeTasksCount}</p>
<div>
<button
type="button"
onClick={() =>
dispatch({ type: 'filterChanged', filter: 'all' })
}
>
Усі
</button>
<button
type="button"
onClick={() =>
dispatch({ type: 'filterChanged', filter: 'active' })
}
>
Активні
</button>
<button
type="button"
onClick={() =>
dispatch({ type: 'filterChanged', filter: 'completed' })
}
>
Виконані
</button>
</div>
<ul>
{visibleTasks.map((task) => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() =>
dispatch({
type: 'taskToggled',
id: task.id
})
}
/>
<span
style={{
textDecoration: task.completed
? 'line-through'
: 'none'
}}
>
{task.title}
</span>
</label>
<button
type="button"
onClick={() =>
dispatch({
type: 'taskDeleted',
id: task.id
})
}
>
Видалити
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() =>
dispatch({ type: 'completedTasksCleared' })
}
>
Видалити виконані
</button>
</main>
);
}Після надсилання форми викликається:
dispatch({
type: 'taskAdded',
id: Date.now(),
title
});Reducer отримує поточний стан і дію:
case 'taskAdded':
return {
...state,
tasks: [
...state.tasks,
{
id: action.id,
title: action.title,
completed: false
}
]
};Оператор розпилення ...state зберігає властивість filter. Оператор ...state.tasks створює новий масив, до якого додається нове завдання.
Для перемикання використовується ідентифікатор:
dispatch({
type: 'taskToggled',
id: task.id
});Reducer створює новий масив за допомогою map:
tasks: state.tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task
)Для потрібного завдання створюється новий об’єкт. Інші завдання повертаються без змін.
Метод filter створює новий масив без завдання з потрібним ідентифікатором:
case 'taskDeleted':
return {
...state,
tasks: state.tasks.filter((task) => task.id !== action.id)
};Reducer має залежати лише від своїх аргументів:
function reducer(state, action) {
// Результат залежить тільки від state та action
}Reducer не повинен:
змінювати state напряму;
виконувати HTTP-запити;
викликати setTimeout;
змінювати DOM;
залежати від випадкових значень або поточного часу під час обчислення нового стану;
виконувати побічні ефекти.
Правильний підхід:
case 'taskToggled':
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task
)
};Неправильний підхід:
case 'taskToggled':
state.tasks.forEach((task) => {
if (task.id === action.id) {
task.completed = !task.completed;
}
});
return state;У неправильному варіанті змінюється наявний об’єкт і повертається той самий об’єкт стану. Це може призвести до некоректних оновлень і ускладнює відстеження змін.
useReducer проти useStateuseState добре підходить, коли стан простий:
const [isOpen, setIsOpen] = useState(false);useReducer зручніший, коли є багато переходів між станами:
const [state, dispatch] = useReducer(reducer, initialState);Обирайте useReducer, якщо:
стан має вкладену структуру;
одна операція змінює кілька властивостей;
є багато обробників, які змінюють один стан;
потрібно централізувати правила переходів;
reducer можна протестувати окремо від компонента.
Не потрібно використовувати useReducer для кожного значення. У прикладі текст поля зберігається через useState, бо це простий локальний стан, а список завдань і фільтр керуються reducer-функцією.
Оскільки reducer — звичайна функція, її можна перевіряти без рендерингу React-компонента:
const state = {
tasks: [],
filter: 'all'
};
const nextState = tasksReducer(state, {
type: 'taskAdded',
id: 1,
title: 'Нове завдання'
});
console.log(nextState);
// {
// tasks: [{ id: 1, title: 'Нове завдання', completed: false }],
// filter: 'all'
// }
console.log(state);
// Початковий стан не змінився:
// { tasks: [], filter: 'all' }Це одна з переваг reducer-підходу: правила зміни стану ізольовані від JSX та подій браузера.
Не змінюйте масиви та об’єкти напряму:
state.tasks.push(newTask);
return state;Створюйте нову структуру:
return {
...state,
tasks: [...state.tasks, newTask]
};Для об’єкта використовуйте копіювання властивостей:
return {
...state,
user: {
...state.user,
name: action.name
}
};Усі додаткові значення повинні бути доступними з об’єкта дії:
dispatch({
type: 'filterChanged',
filter: 'completed'
});Reducer не повинен брати дані з глобальних змінних або замикань компонента.
defaultЯкщо reducer не обробляє дію та повертає undefined, стан компонента буде зіпсований. Додавайте default:
default:
throw new Error(`Невідома дія: ${action.type}`);Помилка одразу вкаже на неправильну дію.
Тип дії має бути рядком, який reducer очікує:
dispatch({ type: 'taskToggled', id: task.id });Якщо reducer обробляє taskToggled, а компонент надсилає toggleTask, відповідний case не виконається.
У прикладі кількість активних завдань обчислюється зі state.tasks:
const activeTasksCount = state.tasks.filter(
(task) => !task.completed
).length;Не обов’язково зберігати activeTasksCount окремо. Якщо це похідне значення, його можна обчислити під час рендерингу. Так ви не ризикуєте, що кількість і масив завдань розійдуться.
useReducer використовується для стану зі складною логікою переходів.
Reducer отримує поточний стан і дію та повертає новий стан.
Дія — це об’єкт із властивістю type і, за потреби, додатковими даними.
dispatch надсилає дію reducer-функції.
Reducer має бути чистою функцією.
Стан не можна мутувати — потрібно створювати нові масиви та об’єкти.
useReducer не замінює useState у всіх випадках: простий локальний стан можна залишити під керуванням useState.
Обчислювані значення краще отримувати зі стану, а не дублювати в ньому.