Синхронизация с помощью эффектов
Некоторым компонентам требуется синхронизация с внешними системами. Например, вам может понадобиться управлять компонентом, не относящимся к React, на основе состояния React, настроить соединение с сервером или отправить аналитический лог, когда компонент появляется на экране. *Эффекты* позволяют запускать некоторый код после рендеринга, чтобы вы могли синхронизировать свой компонент с какой-либо системой вне React.
Вы узнаете
- Что такое эффекты
- Чем эффекты отличаются от событий
- Как объявить эффект в вашем компоненте
- Как избежать ненужного повторного запуска эффекта
- Почему эффекты запускаются дважды при разработке и как это исправить
Что такое эффекты и чем они отличаются от событий?
Прежде чем перейти к эффектам, вам необходимо ознакомиться с двумя типами логики внутри компонентов React:
-
Код рендеринга (представленный в разделе Описание пользовательского интерфейса) находится на верхнем уровне вашего компонента. Именно здесь вы принимаете props и state, преобразуете их и возвращаете JSX, который хотите увидеть на экране. Код рендеринга должен быть чистым. Как и математическая формула, он должен только вычислять результат, но не делать ничего другого.
-
Обработчики событий (представлены в разделе [Добавление интерактивности](/learn/adding -interactivity)) — это вложенные функции внутри ваших компонентов, которые выполняют действия, а не просто их вычисляют. Обработчик событий может обновлять поле ввода, отправлять HTTP-запрос POST для покупки продукта или перенаправлять пользователя на другой экран. Обработчики событий содержат «побочные эффекты» (они изменяют состояние программы), вызванные конкретным действием пользователя (например, нажатием кнопки или вводом текста).
Иногда этого недостаточно. Рассмотрим компонент ChatRoom, который должен подключаться к серверу чата всякий раз, когда он отображается на экране. Подключение к серверу не является чистым вычислением (это побочный эффект), поэтому оно не может происходить во время рендеринга. Однако нет какого-то одного конкретного события, такого как нажатие кнопки, которое вызывает отображение ChatRoom.
Эффекты позволяют вам определять побочные эффекты, вызванные самим рендерингом, а не конкретным событием. Отправка сообщения в чате — это событие, поскольку оно напрямую вызвано нажатием пользователем определенной кнопки. Однако установка соединения с сервером — это эффект, поскольку она должна происходить независимо от того, какое взаимодействие привело к появлению компонента. Эффекты запускаются в конце [commit](/ learn/render-and-commit) после обновления экрана. Это подходящий момент для синхронизации компонентов React с какой-либо внешней системой (например, сетью или сторонней библиотекой).
Здесь и далее в тексте слово «Эффект» с заглавной буквы относится к приведенному выше определению, характерному для React, т. е. к побочному эффекту, вызванному рендерингом. Чтобы обозначить более широкое понятие в программировании, мы будем использовать термин «побочный эффект».
Возможно, вам не нужен Effect
Не спешите добавлять Effects в свои компоненты. Имейте в виду, что Effects обычно используются для «выхода» из кода React и синхронизации с какой-либо внешней системой. Сюда входят API браузера, сторонние виджеты, сеть и т. д. Если ваш Effect лишь корректирует какое-то состояние на основе другого состояния, вам, возможно, не нужен Effect.
Как написать эффект
Чтобы написать эффект, выполните следующие три шага:
- Объявите эффект. По умолчанию ваш эффект будет запускаться после каждого commit.
- Укажите зависимости эффекта. Большинство эффектов должны запускаться повторно только при необходимости, а не после каждого рендеринга. Например, анимация появления должна запускаться только при появлении компонента. Подключение к чату и отключение от него должны происходить только при появлении и исчезновении компонента или при смене чата. Вы узнаете, как это контролировать, указав зависимости.
- Добавьте очистку, если необходимо. Некоторым эффектам нужно указать, как остановить, отменить или очистить то, чем они занимались. Например, для «connect» нужен «disconnect», для «subscribe» нужен «unsubscribe», а для «fetch» нужен либо «cancel», либо «ignore». Вы узнаете, как это сделать, возвращая функцию очистки.
Давайте рассмотрим каждый из этих шагов подробно.
Шаг 1: Объявите эффект
Чтобы объявить эффект в вашем компоненте, импортируйте хук useEffect из React:
import { useEffect } from 'react';Затем вызовите его на верхнем уровне вашего компонента и поместите некоторый код внутрь вашего эффекта:
function MyComponent() {
useEffect(() => {
// Код здесь будет выполняться после *каждого* рендеринга
});
return <div />;
}Каждый раз, когда ваш компонент прорисовывается, React обновляет экран и только потом запускает код внутри useEffect. Другими словами, useEffect «задерживает» выполнение фрагмента кода до тех пор, пока этот рендеринг не отобразится на экране.
Давайте посмотрим, как можно использовать эффект для синхронизации с внешней системой. Рассмотрим компонент React <VideoPlayer>. Было бы неплохо контролировать, воспроизводится ли он или приостановлен, передавая ему проп isPlaying:
<VideoPlayer isPlaying={isPlaying} />;Ваш пользовательский компонент VideoPlayer рендерит встроенный тег браузера <video>:
function VideoPlayer({ src, isPlaying }) {
// TODO: сделать что-то с isPlaying
return <video src={src} />;
}Однако тег браузера <video> не имеет проп isPlaying. Единственный способ управлять им — вручную вызвать методы play() и pause() на элементе DOM.
**Вам необходимо синхронизировать значение isPlaying, которое указывает, должно ли видео в данный момент воспроизводиться, с вызовами таких методов, как play() и pause() `.
**Сначала нам нужно получить ref на узел DOM <video>.
У вас может возникнуть соблазн вызвать play() или pause() во время рендеринга, но это неверно:
import { useState, useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // Вызов этих методов во время рендеринга не допускается.
} else {
ref.current.pause(); // Кроме того, это приводит к сбою.
}
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<>
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Play'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</>
);
}Причина, по которой этот код неверный, заключается в том, что он пытается что-то сделать с узлом DOM во время рендеринга. В React рендеринг должен быть чистым вычислением JSX и не должен содержать побочных эффектов, таких как изменение DOM.
Кроме того, когда VideoPlayer вызывается в первый раз, его DOM еще не существует! Еще нет DOM-узла, чтобы вызвать play() или pause(), потому что React не знает, какой DOM создавать, пока вы не вернете JSX.
Решением здесь является обертывание побочного эффекта с помощью useEffect, чтобы вынести его за пределы вычисления рендеринга:
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying } ) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}Оборачивая обновление DOM в эффект (Effect), вы позволяете React сначала обновить экран. Затем запускается ваш эффект.
Когда ваш компонент VideoPlayer прорисовывается (либо в первый раз, либо при повторной прорисовке), происходит несколько вещей. Во-первых, React обновит экран, обеспечив наличие тега <video> в DOM с правильными пропсами. Затем React запустит ваш эффект. Наконец, ваш эффект вызовет play() или pause() в зависимости от значения isPlaying.
Нажмите «Воспроизвести»/«Пауза» несколько раз и посмотрите, как видеоплеер остается синхронизированным со значением isPlaying:
import { useState, useRef, useEffect } from 'react';function VideoPlayer({ src, isPlaying }) { const ref = useRef(null); useEffect(() => { if (isPlaying) { ref.current.play(); } else { ref.current.pause(); } }); return <video ref={ref} src={src} loop playsInline />;}export default function App() { const [isPlaying, setIsPlaying] = useState(false); return ( <> <button onClick={() => setIsPlaying(!isPlaying)}> {isPlaying ? 'Pause' : 'Play'} </button> <VideoPlayer isPlaying={isPlaying} src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" /> </> );}В этом примере «внешней системой», которую вы синхронизировали с состоянием React, был медиа-API браузера. Вы можете использовать похожий подход, чтобы обернуть устаревший код, не относящийся к React (например, плагины jQuery), в декларативные компоненты React.
Обратите внимание, что управление видеоплеером на практике гораздо сложнее. Вызов play() может завершиться сбоем, пользователь может запустить или приостановить воспроизведение с помощью встроенных элементов управления браузера и т. д. Этот пример сильно упрощен и неполный.
По умолчанию эффекты запускаются после каждого рендеринга. Именно поэтому код, подобный этому, приведет к бесконечному циклу:
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});Эффекты запускаются как результат рендеринга. Установка состояния запускает рендеринг. Установка состояния непосредственно в эффекте — это как подключить розетку к самой себе. Эффект запускается, он устанавливает состояние, что вызывает повторный рендеринг, который запускает эффект, он снова устанавливает состояние, это вызывает еще один повторный рендеринг, и так далее.
Эффекты обычно должны синхронизировать ваши компоненты с внешней системой. Если внешней системы нет и вы просто хотите скорректировать какое-то состояние на основе другого состояния, [вам, возможно, не нужен эффект.] (/learn/you-might-not-need-an-effect)
Шаг 2: Укажите зависимости эффекта
По умолчанию эффекты запускаются после каждого рендеринга. Часто это не то, что вам нужно:
- Иногда это медленно. Синхронизация с внешней системой не всегда происходит мгновенно, поэтому вы можете захотеть пропустить ее, если это не необходимо. Например, вам не нужно переподключаться к серверу чата при каждом нажатии клавиши.
- Иногда это приводит к ошибкам. Например, вам не нужно запускать анимацию появления компонента при каждом нажатии клавиши. Анимация должна воспроизводиться только один раз, когда компонент появляется впервые.
Чтобы продемонстрировать проблему, вот предыдущий пример с несколькими вызовами console.log и полем ввода текста, которое обновляет состояние родительского компонента. Обратите внимание, как ввод текста вызывает повторное выполнение эффекта:
import { useState, useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Calling video.play()');
ref.current.play();
} else {
console.log('Calling video.pause()');
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Play'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</>
);
}input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }Вы можете указать React пропустить ненужное повторное выполнение эффекта, указав массив зависимостей в качестве второго аргумента вызова useEffect. Начните с добавления пустого массива [] в приведенный выше пример в строке 14:
useEffect(() => {
// ...
}, []);Вы должны увидеть ошибку с текстом React Hook useEffect has a missing dependency: 'isPlaying':
import { useState, useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Calling video.play()');
ref.current.play();
} else {
console.log('Calling video.pause()');
ref.current.pause();
}
}, []); // Это вызывает ошибку
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Play'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</>
);
}input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }Проблема в том, что код внутри вашего Effect зависит от проп isPlaying, чтобы определить, что делать, но эта зависимость не была явно объявлена. Чтобы исправить эту проблему, добавьте isPlaying в массив зависимостей:
useEffect(() => {
if (isPlaying) { // Он используется здесь...
// ...
} else {
// ...
}
}, [isPlaying]); // ...поэтому он должен быть объявлен здесь!Теперь все зависимости объявлены, поэтому ошибки нет. Указание [isPlaying] в массиве зависимостей сообщает React, что он должен пропустить повторное выполнение вашего эффекта, если isPlaying такое же, как и во время предыдущего рендеринга. С этим изменением ввод текста в поле ввода не вызывает повторного выполнения эффекта, но нажатие Play/Pause — вызывает:
import { useState, useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Calling video.play()');
ref.current.play();
} else {
console.log('Calling video.pause()');
ref.current.pause();
}
}, [isPlaying]);
return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Пауза' : 'Воспроизвести'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</>
);
}input, button { display: block; margin-bottom: 20px; }
video { width: 250px; }Массив зависимостей может содержать несколько зависимостей. React пропустит повторное выполнение эффекта только в том случае, если все указанные вами зависимости имеют точно такие же значения, как и во время предыдущего рендеринга. React сравнивает значения зависимостей с помощью Object.is. Подробности см. в справочнике по useEffect.
Обратите внимание, что вы не можете «выбирать» свои зависимости. Вы получите ошибку линтера, если указанные вами зависимости не соответствуют тому, чего ожидает React, исходя из кода внутри вашего эффекта. Это помогает выявить многие ошибки в вашем коде. Если вы не хотите, чтобы какой-то код выполнялся повторно, отредактируйте сам код эффекта, чтобы «нуждаться» в этой зависимости.
Поведение без массива зависимостей и с пустым массивом зависимостей [] различается:
useEffect(() => {
// Это выполняется после каждого рендеринга
});
useEffect(() => {
// Это выполняется только при монтировании (когда компонент появляется)
}, []);
useEffect(() => {
// Это выполняется при монтировании *и также* если a или b изменились с момента последнего рендеринга
}, [a, b]);Мы подробно рассмотрим, что означает «монтирование» в следующем шаге.
Почему ref был опущен из массива зависимостей?
Этот эффект использует оба ref и isPlaying, но только isPlaying объявлен как зависимость:
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
},
[isPlaying]);Это связано с тем, что объект ref имеет стабильную идентичность: React гарантирует, что при каждом рендере вы всегда получите один и тот же объект от одного и того же вызова useRef. Он никогда не меняется, поэтому сам по себе никогда не вызовет повторного выполнения эффекта. Поэтому не имеет значения, включаете вы его или нет. Включать его тоже можно:
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying, ref]);Функции set, возвращаемые useState, также имеют стабильную идентичность, поэтому вы часто будете видеть, что их тоже опускают из зависимостей. Если линтер позволяет вам опустить зависимость без ошибок, это безопасно.
Опущение всегда стабильных зависимостей работает только тогда, когда линтер может «увидеть», что объект стабилен. Например, если ref был передан из родительского компонента, вам пришлось бы указать его в массиве зависимостей. Однако это хорошо, потому что вы не можете знать, передает ли родительский компонент всегда один и тот же ref или передает один из нескольких ref условно. Таким образом, ваш эффект зависел бы от того, какой ref передан.
Шаг 3: Добавьте очистку, если необходимо
Рассмотрим другой пример. Вы пишете компонент ChatRoom, который должен подключаться к серверу чата при появлении. Вам предоставлен API createConnection(), который возвращает объект с методами connect() и disconnect(). Как поддерживать подключение компонента, пока он отображается пользователю?
Начните с написания логики Effect:
useEffect(() => {
const connection = createConnection();
connection.connect();
});Подключение к чату после каждого перерисовки будет медленным, поэтому вы добавляете массив зависимостей:
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);Код внутри Effect не использует никаких пропсов или состояния, поэтому ваш массив зависимостей равен [] (пустой). Это указывает React запускать этот код только при «монтировании» компонента, т. е. при его первом появлении на экране.
Давайте попробуем запустить этот код:
import { useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
return <h1>Welcome to the chat!</h1>;
}Этот эффект запускается только при монтировании, поэтому можно было бы ожидать, что «✅ Connecting.» будет выведено в консоль один раз. Однако, если вы проверите консоль, «✅ Connecting...» выводится дважды. Почему это происходит?
Представьте, что компонент ChatRoom является частью более крупного приложения со множеством различных экранов. Пользователь начинает свое путешествие на странице ChatRoom. Компонент монтируется и вызывает connection. connect(). Затем представьте, что пользователь переходит на другой экран — например, на страницу настроек. Компонент ChatRoom отмонтируется. Наконец, пользователь нажимает «Назад», и ChatRoom монтируется снова. Это установит второе соединение — но первое соединение так и не было уничтожено! По мере того как пользователь перемещается по приложению, соединения будут накапливаться.
Такие ошибки легко пропустить без тщательного ручного тестирования. Чтобы помочь вам быстро их обнаружить, в процессе разработки React повторно монтирует каждый компонент один раз сразу после его первоначального монтажа.
Двойное появление записи в логе «✅ Connecting...» поможет вам заметить настоящую проблему: ваш код не закрывает соединение при демонтаже компонента.
Чтобы исправить эту проблему, верните функцию очистки из вашего эффекта:
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);React будет вызывать вашу функцию очистки каждый раз перед повторным запуском эффекта и еще один раз, когда компонент демонтируется (удаляется). Давайте посмотрим, что происходит, когда реализована функция очистки:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, []);
return <h1>Добро пожаловать в чат!</h1>;
}Теперь вы получите три записи в консоли при разработке:
«✅ Подключение...»«❌ Отключено.»«✅ Подключение...»
Это правильное поведение при разработке. Перемонтировав ваш компонент, React проверяет, что переход на другую страницу и возвращение обратно не нарушат работу вашего кода. Отключение и повторное подключение — это именно то, что должно происходить! Если вы правильно реализуете очистку, для пользователя не должно быть никакой разницы между однократным запуском эффекта и его запуском, очисткой и повторным запуском. Дополнительная пара вызовов connect/disconnect появляется потому, что React проверяет ваш код на наличие ошибок в режиме разработки. Это нормально — не пытайтесь это убрать!
В производственной среде вы увидите только одно выведение «✅ Connecting...». Перемонтирование компонентов происходит только в процессе разработки, чтобы помочь вам найти эффекты, требующие очистки. Вы можете отключить Strict Mode, чтобы отказаться от этого поведения в процессе разработки, но мы рекомендуем оставить его включенным. Это позволяет вам находить многие ошибки, подобные описанной выше.
Как справиться с двукратным срабатыванием эффекта на этапе разработки?
React намеренно перемонтирует ваши компоненты на этапе разработки, чтобы найти ошибки, подобные той, что была в последнем примере. Правильный вопрос — не «как запустить эффект один раз», а «как исправить мой эффект, чтобы он работал после перемонтирования».
Обычно решением является реализация функции очистки. Функция очистки должна остановить или отменить все, что делал эффект. Общее правило заключается в том, что пользователь не должен быть в состоянии отличить однократное выполнение эффекта (как в производственной среде) от последовательности setup → cleanup → setup (как это происходит в процессе разработки).
Большинство эффектов, которые вы будете писать, будут соответствовать одному из приведенных ниже типичных шаблонов.
Не используйте refs для предотвращения срабатывания эффектов
Распространенной ошибкой при предотвращении двукратного запуска эффектов в процессе разработки является использование ref, чтобы эффект не запускался более одного раза. Например, вы могли бы «исправить» вышеуказанную ошибку с помощью useRef:
const connectionRef = useRef (null);
useEffect(() => {
// 🚩 Это не исправит ошибку!!!
if (!connectionRef.current) {
connectionRef.current = createConnection();
connectionRef.current.connect();
}
}, []);Это приведет к тому, что вы увидите «✅ Connecting...» только один раз во время разработки, но это не исправит ошибку.
Когда пользователь переходит на другую страницу, соединение по-прежнему не закрывается, и при возвращении создается новое соединение. По мере того как пользователь перемещается по приложению, соединения будут накапливаться, так же, как и до «исправления».
Чтобы исправить ошибку, недостаточно просто запустить эффект один раз. Эффект должен работать после повторного монтирования, а это означает, что соединение нужно очистить, как в приведенном выше решении.
Смотрите примеры ниже, чтобы узнать, как обрабатывать распространенные шаблоны.
Управление виджетами, не написанными на React
Иногда вам нужно добавить виджеты интерфейса, которые не написаны на React. Например, предположим, вы добавляете компонент карты на свою страницу. У него есть метод setZoomLevel(), и вы хотите синхронизировать уровень масштабирования с переменной состояния zoomLevel в вашем коде React. Ваш эффект будет выглядеть примерно так:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);Обратите внимание, что в данном случае очистка не требуется. В процессе разработки React вызовет эффект дважды, но это не является проблемой, поскольку дважды вызов setZoomLevel с одним и тем же значением ничего не делает. Это может быть немного медленнее, но это не имеет значения, так как в производственной среде не произойдет ненужного повторного монтирования.
Некоторые API могут не позволять вызывать их дважды подряд. Например, метод showModal встроенного элемента [<dialog>](https://developer.mozilla.org/ en-US/docs/Web/API/HTMLDialogElement) выдает исключение, если его вызвать дважды. Реализуйте функцию очистки и заставьте ее закрывать диалоговое окно:
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);В процессе разработки ваш эффект будет вызывать showModal(), затем сразу close(), а затем снова showModal(). Это ведет к тому же поведению, видимому пользователю, что и при однократном вызове showModal(), как вы увидите в производственной среде.
Подписка на события
Если ваш эффект подписан на какое-либо событие, функция очистки должна отписаться:
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);В процессе разработки ваш эффект будет вызывать addEventListener(), затем сразу же removeEventListener(), а затем снова addEventListener() с тем же обработчиком. Таким образом, в любой момент времени будет активна только одна подписка. Для пользователя это будет выглядеть так же, как если бы вы вызывали addEventListener() один раз, как в производственной среде.
Запуск анимаций
Если ваш Effect анимирует что-либо, функция очистки должна сбросить анимацию до начальных значений:
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Запустить анимацию
return () => {
node.style.opacity = 0; // Сброс к исходному значению
};
}, []);В процессе разработки прозрачность будет установлена на 1, затем на 0, а затем снова на 1. Это должно иметь такое же видимое для пользователя поведение, как и установка значения 1 напрямую, что и произойдет в производственной среде. Если вы используете стороннюю библиотеку анимации с поддержкой твининга, ваша функция очистки должна сбросить временную шкалу в исходное состояние.
Загрузка данных
Если ваш эффект что-то загружает, функция очистки должна либо прервать загрузку, либо игнорировать ее результат:
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);Вы не можете «отменить» сетевой запрос, который уже произошел, но ваша функция очистки должна гарантировать, что запрос, который уже не актуален, не будет продолжать влиять на ваше приложение. Если userId изменяется с 'Alice' на 'Bob', очистка гарантирует, что ответ 'Alice' будет игнорирован, даже если он поступит после 'Bob'.
При разработке вы увидите два запроса на вкладке «Сеть». В этом нет ничего страшного. При описанном выше подходе первый Effect будет немедленно очищен, так что его копия переменной ignore будет установлена в true. Поэтому, даже если есть лишний запрос, он не повлияет на состояние благодаря проверке if (!ignore).
В производственной среде будет только один запрос. Если второй запрос в процессе разработки вас беспокоит, лучший подход — использовать решение, которое удаляет дубликаты запросов и кэширует их ответы между компонентами:
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...Это не только улучшит процесс разработки, но и сделает ваше приложение более отзывчивым. Например, пользователю, нажавшему кнопку «Назад», не придется ждать повторной загрузки данных, так как они будут кэшированы. Вы можете либо создать такой кэш самостоятельно, либо использовать одну из множества альтернатив ручному извлечению данных в Effects.
Какие есть хорошие альтернативы извлечению данных в Effects?
Написание вызовов fetch внутри Effects — это популярный способ извлечения данных, особенно в полностью клиентских приложениях. Однако это очень ручной подход, и у него есть существенные недостатки:
-
Effects не запускаются на сервере. Это означает, что исходный HTML, отрендеренный на сервере, будет содержать только состояние загрузки без данных. Клиентскому компьютеру придется загрузить весь JavaScript и отрендерить ваше приложение, только чтобы обнаружить, что теперь ему нужно загрузить данные. Это не очень эффективно.
-
Загрузка данных непосредственно в эффектах упрощает создание «сетевых водопадов». Вы рендерите родительский компонент, он загружает некоторые данные, рендерит дочерние компоненты, а затем они начинают загружать свои данные. Если сеть не очень быстрая, это значительно медленнее, чем загрузка всех данных параллельно.
-
Загрузка данных непосредственно в эффектах обычно означает, что вы не предварительно загружаете и не кэшируете данные. Например, если компонент отмонтируется, а затем снова смонтируется, ему придется загружать данные заново.
-
Это не очень эргономично. При написании вызовов
fetchтаким образом, чтобы избежать ошибок, таких как условия гонки, требуется довольно много шаблонного кода.
Этот список недостатков не является специфическим для React. Он применим к получению данных при монтировании с помощью любой библиотеки. Как и в случае с маршрутизацией, правильно организовать извлечение данных — задача не из простых, поэтому мы рекомендуем следующие подходы:
-
Если вы используете фреймворк, воспользуйтесь его встроенным механизмом извлечения данных. Современные фреймворки React имеют встроенные механизмы извлечения данных, которые эффективны и не страдают от вышеперечисленных проблем.
-
В противном случае рассмотрите возможность использования или создания кэша на стороне клиента. Популярные решения с открытым исходным кодом включают TanStack Query, useSWR и React Router 6.4+. Вы также можете создать собственное решение, в этом случае вы будете использовать Effects «под капотом», но добавите логику для дедупликации запросов, кэширования ответов и предотвращения сетевых водопадов (путем предварительной загрузки данных или переноса требований к данным в маршруты).
Вы можете продолжить получение данных напрямую в Effects, если ни один из этих подходов вам не подходит.
Отправка аналитики
Рассмотрим этот код, который отправляет аналитическое событие при посещении страницы:
useEffect(() => {
logVisit(url); // Отправляет запрос POST
}, [url]);В процессе разработки logVisit будет вызываться дважды для каждого URL, поэтому у вас может возникнуть желание исправить это. Мы рекомендуем оставить этот код как есть. Как и в предыдущих примерах, с точки зрения видимого пользователю поведения нет никакой разницы между однократным и двукратным выполнением. С практической точки зрения, logVisit не должен ничего делать на этапе разработки, поскольку вы не хотите, чтобы логи с машин разработчиков искажали производственные метрики. Ваш компонент перемонтируется каждый раз, когда вы сохраняете его файл, поэтому он все равно регистрирует лишние посещения на этапе разработки.
В производственной среде дубликатов записей о посещениях не будет. Чтобы отладить отправляемые вами аналитические события, вы можете развернуть приложение в тестовой среде (которая работает в производственном режиме) или временно отключить Strict Mode и его проверки перемонтирования, предназначенные только для разработки. Вы также можете отправлять аналитику из обработчиков событий смены маршрута вместо Effects. Для более точной аналитики наблюдатели пересечения могут помочь отслеживать, какие компоненты находятся в области просмотра и как долго они остаются видимыми.
Не является эффектом: инициализация приложения
Некоторая логика должна выполняться только один раз при запуске приложения. Вы можете поместить ее вне ваших компонентов:
if (typeof window !== 'undefined') { // Проверка, запущено ли приложение в браузере.
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}Это гарантирует, что такая логика выполняется только один раз после того, как браузер загрузит страницу.
Не эффект: покупка продукта
Иногда, даже если вы напишете функцию очистки, нет способа предотвратить видимые для пользователя последствия двукратного запуска эффекта. Например, возможно, ваш эффект отправляет POST-запрос, такой как покупка продукта:
useEffect(() => {
// 🔴 Неправильно: этот эффект срабатывает дважды при разработке, выявляя проблему в коде.
fetch('/api/buy', { method: 'POST' });
}, []);Вы же не хотите покупать товар дважды.
Однако именно по этой причине вы не должны помещать эту логику в эффект. Что, если пользователь перейдет на другую страницу, а затем нажмет «Назад»? Ваш эффект запустится снова. Вы не хотите покупать товар, когда пользователь посещает страницу; вы хотите купить его, когда пользователь нажимает кнопку «Купить».
Покупка вызывается не рендерингом, а конкретным взаимодействием. Она должна выполняться только тогда, когда пользователь нажимает кнопку. Удалите эффект и переместите запрос /api/buy в обработчик события кнопки «Купить»:
function handleClick() {
// ✅ Покупка — это событие, потому что она вызвана конкретным взаимодействием.
fetch('/api/buy', { method: 'POST' });
}Это иллюстрирует, что если повторный монтирование нарушает логику вашего приложения, это обычно выявляет существующие ошибки. С точки зрения пользователя, посещение страницы не должно отличаться от посещения, нажатия на ссылку, а затем нажатия «Назад» для повторного просмотра страницы. React проверяет, что ваши компоненты соблюдают этот принцип, повторно монтируя их один раз во время разработки.
Сводка
Эта площадка поможет вам «почувствовать», как эффекты работают на практике.
В этом примере используется setTimeout для планирования вывода в консоль текста ввода через три секунды после запуска эффекта. Функция очистки отменяет ожидающий таймаут. Начните с нажатия кнопки « «Подключить компонент»:
import { useState, useEffect } from 'react';
function Playground() {
const [text, setText] = useState('a');
useEffect(() => {
function onTimeout() {
console.log('⏰ ' + text);
}
console.log('🔵 Запланировать вывод «' + text + '» в консоль');
const timeoutId = setTimeout(onTimeout, 3000);
return () => {
console.log('🟡 Отменить вывод «' + text + '» в консоль');
clearTimeout(timeoutId);
};
}, [text]);
return (
<>
<label>
Что занести в журнал:{' '}
<input
value={text}
onChange={e => setText(e.target.value)}
/>
</label>
<h1>{text}</h1>
</>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Unmount' : 'Mount'} the component
</button>
{show && <hr />}
{show && <Playground />}
</>
);
}Сначала вы увидите три записи в журнале: Запись журнала «a», Отмена записи журнала 'a' и снова Запись журнала «a». Через три секунды также появится запись с текстом a. Как вы узнали ранее, дополнительная пара «запланировать/отменить» связана с тем, что React перемонтирует компонент один раз во время разработки, чтобы проверить, правильно ли вы реализовали очистку.
Теперь измените ввод на abc. Если вы сделаете это достаточно быстро, вы увидите запись Запланировать «ab» log, за которым сразу последуют Cancel 'ab' log и Schedule «abc» log.
React всегда очищает предыдущий рендер , прежде чем запустить эффект следующего рендеринга. Вот почему, даже если вы быстро вводите текст в поле ввода, одновременно планируется не более одного таймаута. Измените ввод несколько раз и понаблюдайте за консолью, чтобы понять, как очищаются эффекты. Введите что-нибудь в поле ввода, а затем сразу нажмите «Отмонтировать компонент». Обратите внимание, как отмонтирование очищает эффект последнего рендеринга. Здесь оно очищает последний таймаут, прежде чем он успевает сработать.
Наконец, отредактируйте компонент выше и закомментируйте функцию очистки, чтобы таймауты не отменялись. Попробуйте быстро набрать abcde. Что, по вашему мнению, произойдет через три секунды? Будет ли console.log(text) внутри таймаута выводить самый последний text и генерировать пять записей abcde? Попробуйте, чтобы проверить свою интуицию!
Через три секунды вы должны увидеть последовательность записей (a, ab, abc, abcd и abcde), а не пять записей abcde. Каждый эффект «захватывает» значение text из соответствующего рендеринга. Неважно, что состояние text изменилось: эффект из рендеринга с text = 'ab' всегда будет видеть 'ab'. Другими словами, эффекты из каждого рендеринга изолированы друг от друга. Если вам интересно, как это работает, вы можете прочитать о замыканиях.
Каждый рендер имеет свои собственные эффекты
Вы можете думать о useEffect как о «привязке» некоторого поведения к результату рендеринга. Рассмотрим следующий эффект:
export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to {roomId}!</h1>;
}Давайте посмотрим, что именно происходит, когда пользователь перемещается по приложению.
Начальный рендеринг
Пользователь посещает <ChatRoom roomId="general" />. Давайте мысленно заменим roomId на 'general':
// JSX для первого рендеринга (roomId = «general»)
return <h1>Добро пожаловать в general!</h1>;Эффект также является частью результата рендеринга. Эффект первого рендеринга становится:
// Эффект для первого рендеринга (roomId = «general»)
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Зависимости для первого рендеринга (roomId = «general»)
['general']React запускает этот эффект, который подключается к чату 'general'.
Повторный рендеринг с теми же зависимостями
Допустим, <ChatRoom roomId="general" /> перерисовывается. Вывод JSX остается прежним:
// JSX для второго рендеринга (roomId = «general»)
return <h1>Welcome to general!</h1>;React видит, что результат рендеринга не изменился, поэтому не обновляет DOM. Эффект второго рендеринга выглядит так:
// Эффект для второго рендеринга (roomId = «general»)
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Зависимости для второго рендеринга (roomId = «general»)
['general']React сравнивает ['general'] из второго рендеринга с ['general'] из первого рендеринга. Поскольку все зависимости одинаковы, React игнорирует эффект второго рендеринга. Он никогда не вызывается.
Повторный рендеринг с другими зависимостями
Затем пользователь переходит на страницу <ChatRoom roomId="travel" />. На этот раз компонент возвращает другой JSX:
// JSX для третьего рендеринга (roomId = «travel»)
return <h1>Welcome to travel!</h1>;React обновляет DOM, заменяя «Welcome to general» на «Welcome to travel».
Эффект от третьего рендеринга выглядит так:
// Эффект для третьего рендеринга (roomId = «travel»)
() => {
const connection = createConnection('travel');
connection.connect();
return () => connection.disconnect();
},
// Зависимости для третьего рендеринга (roomId = «travel»)
['travel']React сравнивает ['travel'] из третьего рендеринга с ['general'] из второго рендеринга. Одна зависимость отличается: Object.is('travel', 'general') равно false. Эффект не может быть пропущен.
- Прежде чем React сможет применить эффект из третьего рендеринга, ему нужно очистить последний эффект, который был запущен.* Эффект второго рендеринга был пропущен, поэтому React должен очистить эффект первого рендеринга. Если вы прокрутите вверх до первого рендеринга, то увидите, что его очистка вызывает
disconnect()для соединения, созданного с помощьюcreateConnection('general'). Это отключает приложение от чата'general'. После этого React запускает эффект третьего рендеринга. Он подключается к чату'travel'.
Отключение
Наконец, предположим, что пользователь уходит с страницы, и компонент ChatRoom отключается. React запускает функцию очистки последнего эффекта. Последний эффект был из третьего рендеринга. Очистка третьего рендеринга уничтожает соединение createConnection('travel'). Таким образом, приложение отключается от комнаты 'travel'.
Поведение, доступное только при разработке
Когда включен Strict Mode, React повторно монтирует каждый компонент один раз после монтирования (состояние и DOM сохраняются). Это помогает найти эффекты, требующие очистки и выявляет ошибки, такие как условия гонки, на ранней стадии. Кроме того, React будет повторно монтировать эффекты при каждом сохранении файла в процессе разработки. Оба этих поведения доступны только в режиме разработки.
Вывод
- В отличие от событий, эффекты вызываются самим рендерингом, а не конкретным взаимодействием.
- Эффекты позволяют синхронизировать компонент с какой-либо внешней системой (сторонний API, сеть и т. д.).
- По умолчанию эффекты запускаются после каждого рендеринга (включая начальный).
- React пропустит эффект, если все его зависимости имеют те же значения, что и во время последнего рендеринга.
- Вы не можете «выбирать» свои зависимости. Они определяются кодом внутри эффекта.
- Пустой массив зависимостей (
[]) соответствует «монтированию» компонента, т. е. его добавлению на экран. - В строгом режиме React монтирует компоненты дважды (только в процессе разработки!), чтобы провести стресс-тестирование ваших эффектов.
- Если ваш эффект выходит из строя из-за повторного монтирования, вам нужно реализовать функцию очистки.
- React вызовет вашу функцию очистки перед следующим запуском эффекта и во время демонтирования.
Манипулирование DOM с помощью ref
React автоматически обновляет DOM, чтобы он соответствовал результату рендера, поэтому компонентам обычно не нужно управлять им вручную. Однако иногда может потребоваться доступ к DOM-элементам, которыми управляет React — например, чтобы перевести фокус на узел, прокрутить к нему страницу или измерить его размер и положение. В React нет встроенного способа делать такие вещи, поэтому вам понадобится *ref* для DOM-узла.
Возможно, вам не нужен эффект
Эффекты — это «лазейка» из парадигмы React. Они позволяют вам «выйти за пределы» React и синхронизировать ваши компоненты с какой-либо внешней системой, такой как виджет, не относящийся к React, сеть или DOM браузера. Если никаких внешних систем не задействовано (например, если вы хотите обновить состояние компонента при изменении каких-либо пропсов или состояния), вам не нужен эффект. Удаление ненужных эффектов сделает ваш код более понятным, быстрым в исполнении и менее подверженным ошибкам.