React Typescript

Хук useReducer: структуроване управління складним станом

Глибоке занурення в хук useReducer для керування складним станом у React через чіткі екшени та передбачувану логіку оновлень. Від простих лічильників до багаторівневих форм.

Хук 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-розмітка опущена для стислості...
}

Навіть у цьому спрощеному прикладі виникають системні проблеми:

  1. Розсіяння логіки: кожен обробник події містить дублюючу логіку очищення помилок.
  2. Синхронізація стану: якщо забути оновити одну з пов'язаних змінних, стан стає неконсистентним.
  3. Неможливість відстеження історії: складно зрозуміти, яка послідовність дій призвела до поточного стану.
  4. Тестування: для тестування логіки доводиться монтувати весь компонент і симулювати послідовності подій.
Критична межа, після якої 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
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання 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 Список завдань через useReducer
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання 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 Форма реєстрації через useReducer
Файли проєкту
src
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Переваги централізованої логіки

Порівняйте цю реалізацію з початковим прикладом на useState. Тепер:

  1. Вся логіка стану в одному місці. Щоб зрозуміти, як змінюється стан форми, достатньо прочитати функцію registrationReducer. Не треба збирати фрагменти логіки з різних обробників подій.
  2. Декларативні наміри. Виклик dispatch({ type: "submit_started" }) чітко описує намір, а не механіку реалізації. Це робить код самодокументованим.
  3. Простота тестування. Редюсер — це чиста функція, яку можна тестувати ізольовано без монтування компонента:
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({});
  });
});
  1. Масштабованість. Додавання нового поля чи валідаційного правила зводиться до розширення типу 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).

Використовуйте lazy initialization, коли початковий стан:
  • Завантажується з localStorage або sessionStorage.
  • Обчислюється на основі складних трансформацій пропсів.
  • Парсить дані з URL (query-параметри, хеш-фрагменти).
  • Залежить від асинхронних операцій (хоча для асинхронних даних краще підходить комбінація useState + useEffect).

useState vs useReducer: коли використовувати кожен

Обидва хуки призначені для управління станом, але мають різні зони застосування. Ось систематичне порівняння:

КритерійuseStateuseReducer
Складність стануПрості примітиви або плоскі об'єктиГлибоко вкладені об'єкти, зв'язані поля
Логіка оновленьПроста зміна значенняСкладні переходи, множинні залежності
Розподіл логікиРозсіяна по обробниках подійЦентралізована в редюсері
ТестуванняПотребує монтування компонентаРедюсер тестується ізольовано
ЧитабельністьЗрозуміліша для простих випадківЗрозуміліша для складних автоматів станів
Передача внизПередача setter-функційПередача dispatch + екшен-криейтори
Довгострокова підтримкаМоже розростатись хаотичноМасштабується передбачувано
Не застосовуйте useReducer скрізь автоматично. Для простих випадків (прапорець, лічильник, одне текстове поле) useState є кращим вибором — він прозоріший та вимагає менше коду. Редюсер виправданий, коли логіка стану дійсно складна.

Типові сигнали для переходу на useReducer:

  1. Множинні setState у одному обробнику події. Якщо один клік користувача змінює 3-4 незалежні частини стану, це кандидат на об'єднання у єдиний екшен.
  2. Дублювання логіки між обробниками. Коли кілька обробників містять ідентичні фрагменти коду (очищення помилок, скидання прапорців), редюсер дозволяє винести цю логіку у гілки switch.
  3. Складна валідація або взаємозалежні поля. Форми, де зміна одного поля впливає на доступність або валідацію інших полів, ідеально моделюються через редюсери.
  4. Необхідність повторного відтворення послідовності дій. Для налагодження або логування корисно мати список екшенів, що призвели до поточного стану. Редюсер робить це природно.

Організація екшенів: від 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]
>;

Ця конструкція працює наступним чином:

  1. typeof taskActions — отримуємо тип об'єкта:
    {
      added: (text: string) => { type: "added"; text: string };
      toggled: (id: number) => { type: "toggled"; id: number };
      // ...
    }
    
  2. keyof typeof taskActions — отримуємо об'єднання ключів: "added" | "toggled" | "deleted" | "edited".
  3. (typeof taskActions)[keyof typeof taskActions] — індексація об'єкта всіма ключами одночасно дає об'єднання всіх типів функцій.
  4. ReturnType<...> — витягуємо типи повернення всіх функцій, отримуючи фінальне об'єднання екшенів.

Переваги цього підходу:

  1. Автоматичне виведення типів. Додавання нового action creator автоматично додає його до типу TaskAction — не треба вручну оновлювати об'єднання типів.
  2. Автодоповнення. IDE пропонує всі доступні методи taskActions.* із сигнатурами параметрів.
  3. Захист від помилок. Неможливо випадково створити екшен з неправильним типом або структурою.
  4. Централізований рефакторинг. Зміна структури екшену потребує правки лише в одному місці.
  5. Читабельність. taskActions.added(text) природніше, ніж { type: "added", text }.
Цей підхід є стандартом де-факто у великих проєктах і використовується у Redux Toolkit (функція createSlice генерує саме такий об'єкт action creators автоматично).

Порівняльна таблиця підходів

ПідхідЗахист від помилокАвтодоповненняРефакторингСкладністьРекомендовано для
Інлайнові літерали❌❌❌НизькаПрототипи, навчання
Action Constants✅Часткове✅НизькаМалі проєкти (< 10 екшенів)
Прості функції✅✅✅СередняСередні проєкти (10-30 екшенів)
Об'єкт creators✅✅✅СередняВеликі проєкти (30+ екшенів)
Не переходьте відразу до найскладнішого підходу. Якщо у вашого компонента 3-5 екшенів, інлайнові літерали цілком достатні. Рефакторинг у action creators має сенс, коли ви відчуваєте біль від дублювання коду або помилок у назвах типів.

Практичні завдання


Запитання для самоконтролю


Підсумок

Хук useReducer є потужним інструментом для управління складним станом у React-застосунках. Ключові висновки цієї лекції:

📦 Централізація логіки

Усі переходи стану зібрані в одній чистій функції-редюсері, що спрощує розуміння, тестування та підтримку коду.

🎯 Декларативність

Виклики dispatch описують що сталося (намір користувача), а не як змінити стан (механіку реалізації). Це робить код самодокументованим.

🔒 Типобезпека

TypeScript-типізація через дискримінантні об'єднання забезпечує автодоповнення, перевірку екшенів на етапі компіляції та захист від друкарських помилок.

⚡ Масштабованість

Редюсери природно масштабуються — додавання нових екшенів не вимагає перероблення існуючої логіки, лише нової гілки у switch.

Вибір між useState та useReducer

Остаточне правило дуже просте: починайте з useState, і переходьте до useReducer, коли відчуєте, що логіка стану виходить з-під контролю. useReducer — це не заміна useState, а інструмент для випадків, коли складність вимагає структурованого підходу.

У наступних лекціях ми розглянемо, як поєднувати useReducer із Context API для побудови глобального сховища стану без сторонніх бібліотек, а також як інтегрувати редюсери з асинхронною логікою через middleware-патерни.

Додаткові ресурси

Офіційна документація
посилання
Extracting State Logic into a Reducer — повний посібник від команди React з інтерактивними прикладами.
TypeScript Handbook
посилання
Discriminated Unions — офіційна документація про дискримінантні об'єднання у TypeScript.
Redux Toolkit
бібліотека
Хоча Redux Toolkit призначений для глобального стану, його функція createSlice автоматично генерує екшени та редюсери з TypeScript-типізацією. Корисно вивчити для розуміння масштабованих патернів.
Immer
бібліотека
Бібліотека для роботи з незмінними структурами через мутабельний API. Дозволяє писати state.count++ замість { ...state, count: state.count + 1 }. React Redux та Redux Toolkit використовують Immer під капотом.
Copyright © 2026