React: эффекты, подписки и очистка
Оглавление · События · Асинхронность
Задача: подключить внешний источник событий и освободить подписку, когда она больше не нужна. Нужны чистый рендер, замыкания и отмена ожидания. Примеры локальные: никаких подключений к живой CMS или новым сервисам.
Три разных места для действия
| Что требуется | Где находится логика | Пример |
|---|---|---|
| Вычислить отображение | Рендер | Отфильтровать уже загруженные события |
| Ответить на действие пользователя | Обработчик | Проверить форму по submit |
| Согласовать компонент с внешним ресурсом | Effect | Подписаться на EventTarget |
Effect — процесс синхронизации с внешней системой, а не универсальный обработчик любого изменения state. Он выполняется после принятого React обновления, а не внутри вычисления дерева. При серверном рендере Effect не запускается. useEffect.
useEffect получает setup-функцию. Она может вернуть cleanup, который освобождает
ресурс этого setup. При изменении зависимостей React сначала очищает старую
подписку, потом создаёт новую; при удалении компонента также вызывает cleanup.
Исходник схемы
sequenceDiagram participant React as React participant Old as Подписка на тему A participant New as Подписка на тему B React->>Old: Setup для A React->>Old: Cleanup при изменении темы React->>New: Setup для B React->>New: Cleanup при удалении компонента
Стрелки показывают срок владения ресурсом. Cleanup относится к старому setup и замыканию с его значениями. Это не только «действие при unmount».
Счётчик событий внешнего источника
"use client";
import { useEffect, useState } from "react";
export function TopicCounter({ source, topic }: { source: EventTarget; topic: string }) {
const [count, setCount] = useState(0);
useEffect(() => {
const onMessage = () => setCount((value) => value + 1);
source.addEventListener(topic, onMessage);
return () => source.removeEventListener(topic, onMessage);
}, [source, topic]);
return (
<p>
Получено событий: {count}. Текущая тема: {topic}.
</p>
);
}
Компонент слушает один EventTarget и одну тему. При смене темы счётчик не сбрасывается: здесь он считает всё, что экземпляр получил за время работы. Для счётчика каждой темы нужно другое состояние и правило перехода.
onMessage создаётся внутри setup; cleanup удаляет именно эту функцию.
Новая стрелочная функция в removeEventListener была бы другой ссылкой
и не удаляла бы исходную подписку. Updater получает актуальное значение
очереди, не захватывая фиксированный count первого рендера.
В приложении владелец должен создать EventTarget один раз, например через
const [source] = useState(() => new EventTarget()). Создание нового источника
при каждом рендере постоянно меняло бы зависимость и переподключало эффект.
Это браузерный учебный объект, не serializable prop между Server и Client Components.
Зависимости описывают вход процесса
В этом setup используются source и topic; оба включены в массив.
React сравнивает зависимости через Object.is. Пустой массив не делает
использованные props постоянными, он лишь лишает процесс реакции на их изменение.
Зависимости Effect.
Если в обработчике использовать setCount(count + 1) без зависимости от count,
он захватит старый снимок и перестанет правильно накапливать события.
Добавление count в зависимости переподключало бы подписку после каждого события.
Updater устраняет эту зависимость, сохраняя смысл накопления.
Не подавляйте диагностику зависимостей, чтобы добиться «одного запуска». Измените устройство вычисления или ресурса. Если результат можно вывести из props/state, эффект для setter обычно создаёт лишний цикл синхронизации. Вычисления без Effect.
Strict Mode проверяет симметрию
В разработке Strict Mode может выполнить дополнительный цикл setup → cleanup → setup. После него должна остаться одна рабочая подписка, а не две. Cleanup должен освобождать то, что приобрёл setup. Повторный цикл в разработке.
Монтирование компонента не доказывает однократность бизнес-эффекта. Отправку платежа, публикацию или регистрацию действия пользователя не стоит делать следствием существования компонента. Для ресурсов, которые нельзя безопасно повторно подключить, нужен явный контракт их жизненного цикла.
Асинхронная работа живёт дольше setup
Не объявляйте callback Effect как async: такая функция возвращает Promise,
а React ожидает функцию cleanup или отсутствие результата. Запустите
асинхронную работу внутри синхронного setup и обработайте её отказ.
Для учебной загрузки нужны два независимых средства: AbortSignal для API, который его поддерживает, и запрет принимать ответ после cleanup. Этот полный пример использует локальный loader, переданный как prop:
type PreviewState =
| { status: "loading" }
| { status: "success"; title: string }
| { status: "failure"; message: string };
export function AsyncPreview({ load }: { load: (signal: AbortSignal) => Promise<string> }) {
const [state, setState] = useState<PreviewState>({ status: "loading" });
useEffect(() => {
let active = true;
const controller = new AbortController();
async function run() {
try {
const title = await load(controller.signal);
if (active) setState({ status: "success", title });
} catch (error) {
if (active)
setState({
status: "failure",
message: error instanceof Error ? error.message : "Ошибка источника",
});
}
}
void run();
return () => {
active = false;
controller.abort();
};
}, [load]);
if (state.status === "loading") return <p>Загрузка учебного заголовка…</p>;
if (state.status === "failure") return <p role="alert">{state.message}</p>;
return <p>{state.title}</p>;
}
Этот блок продолжает модуль TopicCounter и использует его импорты.
Учебный контракт: loader стабилен на протяжении одного показа. Если меняется
смысл загрузки, родитель создаёт новый экземпляр с соответствующим key.
Это сбрасывает state в loading. Без этой границы старый результат мог бы
оставаться видимым при замене loader: пример не реализует полноценное обновление
с сохранением или сбросом прошлых данных.
Cleanup запрещает применение позднего результата и просит источник остановиться.
Если loader игнорирует сигнал, работа продолжится, но её результат не будет
принят после очистки. Если возвращён rejected Promise, run обработает его;
void само по себе не обрабатывает отказ. Отмена по-прежнему не откатывает
серверную запись.
Границы текущего проекта
MermaidDiagram загружает Mermaid и
создаёт SVG в Effect. Флаг active не позволяет законченной старой работе
обновить state после cleanup; он не отменяет импорт или вычисление SVG.
Поиск вычисляет совпадения при
рендере и не нуждается в Effect для фильтрации.
Загрузка CMS на главной странице происходит на сервере средствами Next, не через Effect браузерного компонента. Учебный AsyncPreview показывает механику очистки; это не рекомендация перенести серверную загрузку в браузер или вручную создать новый клиентский кеш.
Практика и самопроверка
Сохраните TSX-блоки как временный apps/docs/src/react-effects.tsx и выполните
typecheck. Во временной клиентской странице /lesson/ создайте стабильный
EventTarget и покажите TopicCounter. Кнопка отправляет
source.dispatchEvent(new Event(topic)) в своём обработчике. Переключайте тему
и условно показывайте компонент. События старой темы после переключения не
увеличивают счётчик; после удаления компонента подписки не остаётся.
Покажите AsyncPreview с локальным Promise, у которого вручную сохранены
resolve/reject. Закройте компонент до resolve и проверьте, что поздний ответ
не применяется. Отдельно проверьте отказ, успех и повторное монтирование под
Strict Mode. Для разных учебных запросов задавайте разный key; не проверяйте
«ровно один вызов loader» как гарантию React.
Удалите временные файлы после работы. Объясните, какие ресурсы очищены, какие вычисления продолжаются и почему запуск по кнопке отличается от подписки.