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 |
| Выбранная площадка | ScheduleExplorer | Setter запрашивает новый рендер |
| Отфильтрованные события | Вычисление | Пересчитываются по текущим данным |
| Связь label/select | Экземпляр LocationFilter | useId создаёт связанную пару |
Если фильтр должен сохраняться в ссылке, владельцем становится 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.