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

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

React: события и владельцы состояния

Оглавление · Компоненты и JSX · Переходы состояния

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

Состояние добавляет память

Интерактивную модель можно записать двумя функциями: view: Props × State → Tree и step: State × Event → State. Рендер вычисляет дерево по текущему снимку, обработчик события запрашивает изменение. React хранит состояние экземпляра компонента между рендерами.

Hook useState возвращает значение и функцию обновления. Hooks вызываются на верхнем уровне компонента, в одном порядке, а не внутри условий и циклов. Состояние одного экземпляра компонента не становится автоматически общим с состоянием другого экземпляра.

Снимок и очередь обновлений

"use client";

import { useId, useState } from "react";
import { EventList, type LessonEvent } from "./react-components";

export function Counter() {
  const [count, setCount] = useState(0);
  function incrementThreeTimes() {
    setCount((value) => value + 1);
    setCount((value) => value + 1);
    setCount((value) => value + 1);
  }
  return (
    <button type="button" onClick={incrementThreeTimes}>
      Счётчик: {count}
    </button>
  );
}

onClick получает функцию, а не результат её вызова. Выражение onClick={incrementThreeTimes()} запустило бы её при вычислении JSX. Обработчики событий.

В одном обработчике count относится к снимку конкретного рендера. Три вызова setCount(count + 1) трижды запросят одно и то же значение; они не изменят локальную переменную между строками. Состояние как снимок.

Updater-функции (value) => value + 1 последовательно применяются к ожидающему значению очереди. Здесь один клик добавляет три. Они должны оставаться чистыми: нельзя отправлять запрос или менять внешнюю коллекцию внутри updater. Очередь обновлений.

Это не обещание точного числа рендеров. React может объединять обновления, а в режиме разработки повторные вызовы помогают обнаруживать нечистый код. Проверяйте итоговую модель и интерфейс, а не число вызовов компонента.

Фильтр с одним владельцем

Следующий блок продолжает тот же модуль. Для запуска нужны также EventList и тип из предыдущей главы в соседнем react-components.tsx.

function LocationFilter({
  value,
  locations,
  onChange,
}: {
  value: string;
  locations: readonly string[];
  onChange: (location: string) => void;
}) {
  const id = useId();
  return (
    <div>
      <label htmlFor={id}>Площадка</label>
      <select id={id} value={value} onChange={(event) => onChange(event.target.value)}>
        <option value="">Все площадки</option>
        {locations.map((location) => (
          <option key={location} value={location}>
            {location}
          </option>
        ))}
      </select>
    </div>
  );
}

export function ScheduleExplorer({ entries }: { entries: readonly LessonEvent[] }) {
  const [location, setLocation] = useState("");
  const locations = [...new Set(entries.map((entry) => entry.location))];
  const selectedExists = location === "" || locations.includes(location);
  const activeLocation = selectedExists ? location : "";
  const visible =
    activeLocation === "" ? entries : entries.filter((entry) => entry.location === activeLocation);
  return (
    <section>
      <h2>Учебная программа</h2>
      <LocationFilter value={activeLocation} locations={locations} onChange={setLocation} />
      <button type="button" onClick={() => setLocation("")}>
        Сбросить фильтр
      </button>
      <p role="status">Найдено событий: {visible.length}</p>
      <EventList entries={visible} />
    </section>
  );
}

Входной контракт: непустые названия площадок, уникальные ID событий. Пустая строка зарезервирована для «все площадки». Если реальная предметная модель допускает пустое название, нужен отдельный ID или размеченный вариант.

location хранится у общего родителя фильтра и списка. LocationFilter получает значение и callback: он управляемый, без собственной копии выбранной площадки. EventList получает уже отфильтрованные данные. Это подъём состояния к ближайшему общему владельцу. Совместное состояние.

Исходник схемы
flowchart TD
  Owner["ScheduleExplorer: location"] -->|value и locations| Filter["LocationFilter"]
  Filter -->|onChange с новым значением| Owner
  Owner -->|visible| List["EventList"]
  Owner -->|visible.length| Count["Число найденных событий"]

Стрелки показывают данные и уведомление о выборе. Дочерний компонент не меняет поле родителя напрямую; родитель решает, как применить уведомление.

useId связывает label и select, в том числе при двух экземплярах лаборатории. Это идентификатор элемента интерфейса, не ID события и не ключ списка. useId.

Производное значение не нужно хранить второй раз

visible, число найденных событий и список площадок вычисляются из props и location. Сохранение их в отдельных useState создало бы обязанность синхронизировать несколько копий. Здесь достаточно вычислить их при рендере. Структура состояния.

Если входные события обновились и выбранная площадка исчезла, пример временно показывает все площадки через activeLocation. Само сохранённое location не очищается: если площадка вернётся, прежний выбор восстановится. Это осознанная политика учебного примера. Для окончательного сброса нужно поменять владельца или правило перехода, а не вызвать setter во время рендера.

Фильтрация нескольких записей не требует useMemo. Мемоизация не исправляет неверное владение данными; потребность в оптимизации проверяют на конкретном объёме и измерении.

Что принадлежит этому компоненту

ДанныеВладелецЧто происходит при обновлении
Исходные событияРодитель / источникПриходят новые props
Выбранная площадкаScheduleExplorerSetter запрашивает новый рендер
Отфильтрованные событияВычислениеПересчитываются по текущим данным
Связь label/selectЭкземпляр LocationFilteruseId создаёт связанную пару

Если фильтр должен сохраняться в ссылке, владельцем становится URL и правила роутера. Если выбор нужен нескольким далёким веткам, сначала ищем общий контекст и границы, затем выбираем Context или внешний store. Серверный кеш и данные CMS не превращаются в локальный useState только потому, что видны на странице.

В Next App Router этот интерактивный модуль начинает клиентскую границу через "use client". Это возможность использовать состояние и обработчики, а не утверждение, что HTML появляется только после загрузки JS. Серверная загрузка и передача props подробно описаны в главе Next.js.

Где это видно в Atmanki

Поиск учебника хранит query и includeHistory, а нормализованный запрос и совпадения вычисляет при рендере. Обработчики поля и checkbox меняют исходное состояние. Запросов в сеть при фильтрации здесь нет. UIKit принимает данные и слоты, не загружая контент CMS внутри своих компонентов.

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

Сохраните блоки как временный apps/docs/src/react-state.tsx, выполните typecheck, затем создайте временный apps/docs/src/app/lesson/page.tsx:

import { fixtures } from "@/react-components";
import { Counter, ScheduleExplorer } from "@/react-state";

export default function LessonPage() {
  return (
    <>
      <Counter />
      <ScheduleExplorer entries={fixtures} />
      <ScheduleExplorer entries={fixtures} />
    </>
  );
}

Запустите pnpm docs:dev, откройте /lesson/ и проверьте все площадки, выбор Шатра и сброс. Ожидается соответственно 2, 1 и 2 события. Проверьте второй экземпляр: выбор первого не меняет второй, а label каждого указывает на своё поле.

Покажите Counter рядом: два клика дают 6. Временно замените updater на setCount(count + 1) и объясните результат двух кликов. Верните корректный вариант.

Передайте новый список без Шатра, затем верните исходные props и проверьте описанную политику восстановления выбора. Проверьте клавиатурой label/select и кнопку сброса; DOM-проверка не заменяет проверку фокуса и читаемости. Удалите временные файлы и восстановите страницу после упражнения.

Объясните, почему visible не является независимым состоянием и где должен жить фильтр, если им надо делиться через URL.