Учебник веб-разработки
Разделы учебника
На этой странице

IV. React и состояние

Flux, useReducer и Context

Оглавление · Переходы состояния · Владельцы состояния

Задача: собрать изменения фильтра в одном reducer и передать состояние нескольким веткам интерфейса. Нужны props, события и чистые переходы. Это учебная альтернатива прежней лаборатории, не новая зависимость production.

Однонаправленный цикл

Пользовательское действие превращается в событие, владелец применяет правило изменения, интерфейс получает новое состояние. Компонент не меняет чужую модель напрямую. Важно иметь понятные входы изменения и источник истины.

Исходник схемы
flowchart LR
  View["Интерфейс"] -->|действие пользователя| Action["Событие"]
  Action --> Owner["Владелец состояния"]
  Owner -->|новый снимок| View

Петля означает повторение цикла во времени, а не двустороннюю запись одного поля. Событие выражает намерение, состояние — текущую модель.

Исторический Flux задавал dispatcher, stores и views: dispatcher распределял события, stores обновляли состояние и уведомляли views. Это архитектурный паттерн; он не равен Redux и не требует, чтобы store был чистым reducer. Первоначальное описание Flux.

React useReducer позволяет использовать чистую функцию перехода внутри компонента. Он не создаёт исторический dispatcher Flux, middleware или глобальный store. Сходство здесь в направлении изменений и явных событиях.

Чистое правило изменения фильтра

"use client";

import { createContext, useContext, useId, useReducer, type Dispatch, type ReactNode } from "react";
import { EventList, type LessonEvent } from "./react-components";

type FilterState = { readonly query: string; readonly location: string };
type FilterAction =
  | { type: "queryChanged"; query: string }
  | { type: "locationChanged"; location: string }
  | { type: "reset" };

export const initialFilter: FilterState = { query: "", location: "" };

function unreachable(value: never): never {
  throw new Error("Unexpected filter action");
}

export function filterReducer(state: FilterState, action: FilterAction): FilterState {
  switch (action.type) {
    case "queryChanged":
      return { ...state, query: action.query };
    case "locationChanged":
      return { ...state, location: action.location };
    case "reset":
      return initialFilter;
    default:
      return unreachable(action);
  }
}

export function selectVisible(entries: readonly LessonEvent[], state: FilterState) {
  const query = state.query.trim().toLocaleLowerCase("ru");
  const activeLocation = entries.some((entry) => entry.location === state.location)
    ? state.location
    : "";
  return entries.filter(
    (entry) =>
      (activeLocation === "" || entry.location === activeLocation) &&
      entry.title.toLocaleLowerCase("ru").includes(query),
  );
}

reset меняет оба поля одной операцией. Вычисление visible по-прежнему не хранится отдельным state. Входной контракт прежний: непустые площадки, уникальные ID событий. Исчезнувшая площадка временно означает «все»; если она вернулась, сохранённый выбор снова применяется.

Reducer не запрашивает CMS и не записывает localStorage. Он получает значения, возвращает новое состояние и допускает самостоятельную проверку трасс. Объединять две строки в reducer полезно здесь для учебного сравнения; само количество полей не является причиной усложнять работающий useState.

Context передаёт значение по дереву

Следующий блок продолжает тот же модуль:

const FilterStateContext = createContext<FilterState | null>(null);
const FilterDispatchContext = createContext<Dispatch<FilterAction> | null>(null);

export function useFilterState() {
  const state = useContext(FilterStateContext);
  if (state === null) throw new Error("FilterProvider is missing");
  return state;
}

function useFilterDispatch() {
  const dispatch = useContext(FilterDispatchContext);
  if (dispatch === null) throw new Error("FilterProvider is missing");
  return dispatch;
}

export function FilterProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(filterReducer, initialFilter);
  return (
    <FilterStateContext value={state}>
      <FilterDispatchContext value={dispatch}>{children}</FilterDispatchContext>
    </FilterStateContext>
  );
}

function FilterControls({ entries }: { entries: readonly LessonEvent[] }) {
  const state = useFilterState();
  const dispatch = useFilterDispatch();
  const id = useId();
  const locations = [...new Set(entries.map((entry) => entry.location))];
  const location = locations.includes(state.location) ? state.location : "";
  return (
    <div>
      <label htmlFor={`${id}-query`}>Заголовок</label>
      <input
        id={`${id}-query`}
        value={state.query}
        onChange={(event) => dispatch({ type: "queryChanged", query: event.target.value })}
      />
      <label htmlFor={`${id}-location`}>Площадка</label>
      <select
        id={`${id}-location`}
        value={location}
        onChange={(event) => dispatch({ type: "locationChanged", location: event.target.value })}
      >
        <option value="">Все площадки</option>
        {locations.map((item) => (
          <option key={item} value={item}>
            {item}
          </option>
        ))}
      </select>
      <button type="button" onClick={() => dispatch({ type: "reset" })}>
        Сбросить
      </button>
    </div>
  );
}

function FilterResults({ entries }: { entries: readonly LessonEvent[] }) {
  const state = useFilterState();
  const visible = selectVisible(entries, state);
  return (
    <>
      <p role="status">Найдено: {visible.length}</p>
      <EventList entries={visible} />
    </>
  );
}

export function FilterLab({ entries }: { entries: readonly LessonEvent[] }) {
  return (
    <FilterProvider>
      <FilterControls entries={entries} />
      <FilterResults entries={entries} />
    </FilterProvider>
  );
}

Синтаксис <Context value={...}> поддерживается React 19 текущего проекта. Ближайший provider выше потребителя определяет прочитанное значение. null по умолчанию позволяет заметить пропущенного владельца, а не молча использовать фиктивное состояние. Чтение Context.

Context передаёт значение, хранит его здесь useReducer внутри FilterProvider. Два экземпляра FilterLab создают независимых владельцев. Для общего фильтра provider должен охватывать обе нужные ветки. Это область владения, а не автоматически «глобальное состояние».

Состояние и dispatch разделены на два контекста. Изменение state уведомляет читателей этого state-контекста; dispatch от useReducer имеет стабильную ссылку. Это не гарантирует отсутствие всех рендеров остальных компонентов: родитель и другие входы также могут вызвать рендер. Reducer с Context.

Что dispatch не обещает

dispatch(action) запрашивает обновление. Значение state в уже выполняющемся обработчике остаётся снимком прежнего рендера. Dispatch не возвращает Promise завершённой бизнес-операции. useReducer.

Если нужно загрузить данные, отдельно определите событие начала, выполнение эффекта и событие результата. Reducer сам не отправляет запрос. В Strict Mode чистый reducer может вызываться повторно для проверки; записывать данные на сервер из reducer нельзя.

Для внешнего store нужен подходящий контракт подписки и снимка, а не перенос произвольной изменяемой переменной в Context. Интеграцию внешнего хранилища рассмотрим в сравнительной лаборатории.

Применение и границы

UiProvider передаёт тему через MantineProvider; это не store расписания. Поиск учебника пока решает свою задачу двумя useState. Его не требуется переделывать в reducer ради одинакового стиля примеров.

В учебной FilterLab props могли бы быть проще Context: дочерние компоненты расположены рядом. Context здесь показывает механизм для более глубоких веток, не обосновывает его необходимость во всех формах и фильтрах.

Практика и самопроверка

Сохраните TSX-блоки как временный apps/docs/src/react-flux.tsx, рядом с react-components.tsx из первой главы. Выполните typecheck. На временной странице /lesson/ покажите <FilterLab entries={fixtures} />.

Для двух учебных событий проверьте запрос мастер, площадку Шатёр и сброс. Ожидается 1, 1 и 2 записи. Запрос открытие вместе с Шатром даёт 0. Два экземпляра лаборатории независимы. Временно покажите потребителя без provider: получите явную ошибку. Верните корректную структуру.

Проверьте reducer отдельно на трассе queryChanged → locationChanged → reset: итог равен initialFilter по содержимому, прежние состояния не изменены. Объясните, чем отличаются событие, reducer, provider и вычисляемый результат. Удалите временные файлы после упражнения.