| title | Эффекты для синхронизации |
|---|
Некоторые компоненты требуют синхронизации с внешними системами. Например, может возникнуть необходимость взаимодействовать с компонентом, не связанным с React, в зависимости от состояния вашего приложения, установить соединение с сервером или отправить данные аналитики при появлении компонента на экране. Эффекты позволяют выполнять код после рендеринга, обеспечивая синхронизацию компонента с системами вне React.
- Что такое Эффекты
- Чем Эффекты отличаются от событий
- Как объявить Эффект в компоненте
- Как избежать лишних перезапусков Эффектов
- Почему в режиме разработки Эффекты запускаются дважды и как это исправить
Что такое Эффекты и чем Эффекты отличаются от событий? {/what-are-effects-and-how-are-they-different-from-events/}
Прежде чем перейти к Эффектам, вам нужно познакомиться с двумя типами логики внутри компонентов React:
-
Код рендеринга (подробнее Описание UI) находится на верхнем уровне вашего компонента. Здесь вы берёте пропсы и состояние, преобразуете их и возвращаете тот JSX, который вы хотите видеть на экране. Код рендеринга должен быть чистым. Как и математическая формула, он должен только вычислять результат, не выполняя других действий.
-
Обработчики событий (подробнее Добавление интерактивности) — это вложенные функции внутри ваших компонентов, которые выполняют действия, а не просто их вычисляют. Обработчики событий могут обновлять поля ввода, отправлять HTTP POST-запросы для покупки продукта или перенаправлять пользователя на другой экран. Обработчики событий содержат "побочные эффекты", вызванные конкретными действиями пользователя (например, клик по кнопке или набор текста).
Иногда этого недостаточно. Рассмотрим компонент ChatRoom, который должен подключаться к серверу чата каждый раз, когда он появляется на экране. Подключение к серверу — это не чистое вычисление (это побочный эффект), поэтому его невозможно выполнить во время рендеринга компонента. Однако, не происходит и какого-то конкретного события, подобного клику, который отображал бы ChatRoom.
Эффекты позволяют указать побочные эффекты, вызванные самим рендерингом, а не конкретным событием. Отправка сообщения в чате является событием, потому что происходит после того, как пользователь нажимает на определённую кнопку. В то же время, установка соединения с сервером является Эффектом, так как она должна произойти независимо от того, какое взаимодействие вызвало появление компонента. Эффекты выполняются в конце процесса фиксации, после того как экран обновится. Это подходящий момент для синхронизации компонентов React с какой-либо внешней системой (например, сетью или сторонней библиотекой).
Здесь и далее в тексте «Эффект», написанный с заглавной буквы, относится к приведённому выше определению, специфичному для React, то есть к побочному эффекту, вызванному рендерингом. Чтобы отличать его от концепта, применимого в программировании в целом, мы будем называть последний «побочные эффекты».
Не торопитесь добавлять Эффекты в ваши компоненты. Помните, что Эффекты обычно используются для того, чтобы выйти за пределы вашего React-кода и синхронизироваться с внешними системами. К ним относятся API браузера, сторонние виджеты, сеть и тому подобное. Если ваш Эффект лишь устанавливает одно состояние на основании другого состояния, возможно, Эффект вам не нужен.
Чтобы написать Эффект, следуйте трём шагам:
- Объявите Эффект. По умолчанию Эффект будет запускаться после каждой фазы фиксации.
- Укажите зависимости Эффекта. Большинство Эффектов должны перезапускаться, только когда это необходимо, а не при каждом рендере. Например, анимация должна срабатывать только при появлении компонента. Подключение к чату или отключение от него должно происходить, только когда компонент появляется или исчезает, или когда чат меняется. Вы узнаете, как контролировать это устанавливая зависимости.
- Добавьте функцию очистки при необходимости. Некоторые Эффекты нуждаются в указании, как остановить, отменить или очистить то, что они делали. Например, "установка связи" нуждается "в разрыве связи", "подписка" нуждается в "отписке", а "запрос данных" может нуждаться в "отмене" или "игнорировании". Вы узнаете, как делать это возвращая функцию очистки.
Давайте взглянем на каждый из этих шагов подробнее.
Чтобы объявить Эффект в вашем компоненте, импортируйте useEffect Hook из React:
import { useEffect } from 'react';Затем вызовите его на верхнем уровне вашего компонента и поместите любой код внутрь Эффекта:
function MyComponent() {
useEffect(() => {
// Код здесь будет выполняться после *каждого* рендера
});
return <div />;
}Каждый раз, когда ваш компонент перерисовывается, React обновляет отображение, а затем запускает код внутри. Другими словами, 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 ? 'Пауза' : 'Воспроизведение'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</>
);
}button { display: block; margin-bottom: 20px; }
video { width: 250px; }Этот код некорректен, потому что он пытается взаимодействовать с 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 в Эффект, вы позволяете 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 ? 'Пауза' : 'Воспроизведение'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</>
);
}button { display: block; margin-bottom: 20px; }
video { width: 250px; }В этом примере "внешней системой", которую вы синхронизировали с состоянием React, было браузерное медиа API. Вы можете использовать похожий подход, чтобы обернуть устаревший код, не использующий React (например, плагины jQuery), в декларативные компоненты React.
Обратите внимание, что управление видеоплеером на практике гораздо сложнее. Вызов play() может не сработать, пользователь может воспроизводить или останавливать видео, используя встроенные элементы управления браузера, и так далее. Этот пример очень упрощён и неполон.
По умолчанию Эффекты запускаются после каждого рендеринга. По этой причине код, подобный этому, вызовет бесконечный цикл:
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});Эффекты запускаются как результат рендеринга. Установка состояния запускает рендеринг. Установка состояния немедленно в Эффекте — это как подключить розетку в саму себя. Эффект запускается, он устанавливает состояние, что вызывает повторный рендеринг, что снова устанавливает состояние, и так далее.
Эффекты обычно должны синхронизировать ваши компоненты с внешней системой. Если это не внешняя система, и вы только хотите обновить одно состояние на основе другого состояния, возможно, вам не нужен Эффект.
По умолчанию Эффекты запускаются после каждого рендеринга. Часто это не то, что вам нужно:
- Иногда это медленно. Синхронизация с внешней системой не всегда происходит мгновенно, поэтому имеет смысл избегать этого процесса, если в этом нет необходимости. Например, вам не нужно переподключаться к серверу чата при каждом нажатии клавиши.
- Иногда это неправильно. Например, вы не хотите запускать анимацию появления компонента при каждом нажатии клавиши. Анимация должна воспроизводиться только один раз, когда компонент появляется в первый раз.
Чтобы продемонстрировать проблему, вот предыдущий пример с несколькими вызовами console.log и текстовым полем, которое обновляет состояние родительского компонента. Обратите внимание, как ввод текста вызывает повторный запуск Эффекта:
import { useState, useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Вызов video.play()');
ref.current.play();
} else {
console.log('Вызов 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 ? 'Пауза' : 'Воспроизведение'}
</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 отсутствует зависимость: 'isPlaying':
import { useState, useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Вызов video.play()');
ref.current.play();
} else {
console.log('Вызов 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 ? 'Пауза' : 'Воспроизведение'}
</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; }Проблема в том, что код внутри вашего Эффекта зависит от пропса isPlaying, чтобы определить, что делать, но эта зависимость не была явно указана. Чтобы исправить эту проблему, добавьте isPlaying в массив зависимостей:
useEffect(() => {
if (isPlaying) { // Он используется здесь...
// ...
} else {
// ...
}
}, [isPlaying]); // ...поэтому он должен быть объявлен здесь!Теперь все зависимости объявлены, поэтому ошибки нет. Указание [isPlaying] в качестве массива зависимостей говорит React, что он должен пропустить повторный запуск вашего Эффекта, если isPlaying остаётся таким же, как и во время предыдущего рендеринга. С этим изменением ввод текста в поле не вызывает повторный запуск Эффекта, но нажатие кнопки Воспроизведение/Пауза — вызывает:
import { useState, useRef, useEffect } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
console.log('Вызов video.play()');
ref.current.play();
} else {
console.log('Вызов 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 и 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 передан.
Рассмотрим другой пример. Вы пишете компонент ChatRoom, который должен подключаться к серверу чата, когда он появляется. Вам предоставлен API createConnection(), который возвращает объект с методами connect() и disconnect(). Как сохранить подключение компонента, пока он отображается пользователю?
Начните с написания логики Эффекта:
useEffect(() => {
const connection = createConnection();
connection.connect();
});Подключаться к чату после каждого перерендера было бы медленно, поэтому вы добавляете массив зависимостей:
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);Код внутри эффекта не использует никаких пропсов или состояния, поэтому ваш массив зависимостей — [] (пустой). Это говорит React о том, что этот код следует выполнять только, когда компонент "монтируется", т.е. появляется на экране в первый раз.
Давайте попробуем запустить этот код:
import { useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);
return <h1>Добро пожаловать в чат!</h1>;
}export function createConnection() {
// Фактическая реализация в действительности будет подключаться к серверу
return {
connect() {
console.log('✅ Подключение...');
},
disconnect() {
console.log('❌ Отключено.');
}
};
}input { display: block; margin-bottom: 20px; }Этот Эффект выполняется только при монтировании, поэтому вы могли бы ожидать, что "✅ Подключение..." будет выведено в консоль один раз. Тем не менее, если вы заглянете в консоль, то увидите, что "✅ Подключение..." выводится дважды. Почему это происходит?
Представьте, что компонент ChatRoom является частью более крупного приложения с множеством различных экранов. Пользователь начинает своё путешествие на странице ChatRoom. Компонент монтируется и вызывает connection.connect(). Затем пользователь переходит на другой экран — например, на страницу Настроек. Компонент ChatRoom размонтируется. Наконец, пользователь нажимает Назад, и ChatRoom снова монтируется. Это приведёт к созданию второго подключения, в то время как первое подключение так и не было закрыто! По мере того, как пользователь перемещается по приложению, подключения будут накапливаться.
Ошибки подобного рода легко упустить без тщательного ручного тестирования. Чтобы помочь вам быстро их обнаружить, в режиме разработки React размонтирует каждый компонент один раз сразу после его первоначального монтирования.
Наблюдение за тем, что лог "✅ Подключение..." выводится дважды, помогает вам заметить настоящую проблему: ваш код не закрывает подключение, когда компонент размонтируется.
Чтобы исправить проблему, верните функцию очистки из вашего Эффекта:
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>;
}export function createConnection() {
// Фактическая реализация в действительности будет подключаться к серверу
return {
connect() {
console.log('✅ Подключение...');
},
disconnect() {
console.log('❌ Отключено.');
}
};
}input { display: block; margin-bottom: 20px; }Теперь вы получаете три лога в консоли в режиме разработки:
"✅ Подключение...""❌ Отключено.""✅ Подключение..."
Это правильное поведение в режиме разработки. Размонтируя ваш компонент, React проверяет, что переход на другой экран и обратно не сломает ваш код. Отключение, а затем повторное подключение — это именно то, что должно происходить! Когда вы правильно реализуете функцию очистки, не должно быть заметной разницы для пользователя между выполнением Эффекта один раз и его выполнением, очисткой и повторным выполнением. Пара дополнительных вызовов подключения/отключения возникает потому, что React проверяет ваш код на наличие ошибок в режиме разработки. Это нормально — не пытайтесь это устранить!
В продакшене вы увидите, что "✅ Подключение..." выводится только один раз. Размонтирование компонентов происходит только в режиме разработки, чтобы помочь вам обнаружить Эффекты, которые нуждаются в очистке. Вы можете отключить Strict Mode, чтобы отказаться от поведения в режиме разработки, но мы рекомендуем оставить его включённым. Это позволяет вам находить множество ошибок, подобных описанной выше.
Как управлять двойным срабатыванием Эффекта в процессе разработки? {/how-to-handle-the-effect-firing-twice-in-development/}
React намеренно повторно монтирует ваши компоненты в режиме разработки, чтобы находить ошибки, как в последнем примере. Правильный вопрос не в том, "как запустить Эффект один раз", а в том, "как исправить мой Эффект, чтобы он работал после повторного монтирования".
Обычно ответ заключается в реализации функции очистки. Функция очистки должна останавливать или отменять то, что делал Эффект. Правило заключается в том, что пользователь не должен отличать выполнение Эффекта один раз (как в производственной среде) от последовательности настройка → очистка → настройка (как это происходит в режиме разработки).
Большинство Эффектов, которые вы будете писать, будут соответствовать одному из общих шаблонов ниже.
Не используйте рефы, чтобы предотвратить срабатывание Эффектов. {/dont-use-refs-to-prevent-effects-from-firing/}
Распространённая ошибка при предотвращении двойного срабатывания Эффектов в режиме разработки — это использование ref, чтобы предотвратить выполнение Эффекта более одного раза. Например, вы могли бы "исправить" вышеупомянутую ошибку с помощью useRef:
const connectionRef = useRef(null);
useEffect(() => {
// 🚩 Это не исправит ошибку!!!
if (!connectionRef.current) {
connectionRef.current = createConnection();
connectionRef.current.connect();
}
}, []);Это позволяет видеть "✅ Подключение..." только один раз в режиме разработки, но не решает проблему.
Когда пользователь уходит со страницы, соединение всё равно не закрывается, и когда он возвращается, создаётся новое соединение. По мере того как пользователь перемещается по приложению, соединения будут накапливаться, так же как и до "исправления".
Чтобы устранить ошибку, недостаточно просто сделать так, чтобы Эффект срабатывал один раз. Эффект должен корректно работать после повторного монтирования, что означает, что соединение должно быть очищено, как в решении выше.
Смотрите примеры ниже, чтобы понять, как обрабатывать типичные шаблоны.
Иногда необходимо добавить пользовательские виджеты, которые не написаны на React. Допустим, вы добавляете компонент карты на свою страницу. У него есть метод setZoomLevel(), и вы хотите синхронизировать уровень масштабирования с переменной состояния zoomLevel в вашем React коде. Ваш Эффект будет выглядеть примерно так:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);Обратите внимание, что в этом случае очистка не требуется. В режиме разработки React вызовет Эффект дважды, но это не проблема, потому что вызов setZoomLevel дважды с одним и тем же значением ничего не делает. Это может быть немного медленнее, но это не имеет значения, так как в продакшен-режиме повторное монтирование не произойдёт без необходимости.
Некоторые API могут ограничивать возможность вызывать их дважды подряд. Например, метод showModal встроенного элемента <dialog> вызывает ошибку, если вы вызываете его дважды. Реализуйте функцию очистки, которая будет закрывать диалог:
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() один раз в продакшене.
Если Эффект анимирует что-то, функция очистки должна сбросить анимацию к начальным значениям:
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'.
В процессе разработки вы увидите два запроса в вкладке Сеть. В этом нет ничего плохого. С вышеописанным подходом первый Эффект будет немедленно очищен, поэтому его копия переменной ignore будет установлена в true. Таким образом, даже если будет дополнительный запрос, он не повлияет на состояние благодаря проверке if (!ignore).
В продакшен-режиме будет только один запрос. Если второй запрос в процессе разработки вас беспокоит, лучшим подходом будет использование решения, которое устраняет дублирование запросов и кэширует их ответы между компонентами:
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...Это не только улучшит опыт разработки, но и сделает ваше приложение более отзывчивым. Например, пользователь, нажимающий кнопку Назад, не будет ждать, пока данные загрузятся снова, потому что они будут кэшированы. Вы можете либо создать такой кэш самостоятельно, либо использовать одно из множества альтернативных решений для ручного получения данных в Эффектах.
Какие существуют хорошие альтернативы получению данных в Эффектах? {/what-are-good-alternatives-to-data-fetching-in-effects/}
Запись вызовов fetch внутри Эффектов — это популярный способ получения данных, особенно в полностью клиентских приложениях. Однако это довольно трудоёмкий подход, и он имеет значительные недостатки:
- Эффекты не выполняются на сервере. Это означает, что начальный HTML, отрендеренный на сервере, будет содержать только состояние загрузки без данных. Клиентскому компьютеру придётся загрузить весь JavaScript и отрендерить ваше приложение, только чтобы обнаружить, что теперь ему нужно загрузить данные. Это не очень эффективно.
- Прямое получение данных в Эффектах может легко привести к созданию "сетевых водопадов". Вы рендерите родительский компонент, он получает некоторые данные, рендерит дочерние компоненты, и затем они начинают получать свои данные. Если сеть не очень быстрая, это значительно медленнее, чем получение всех данных параллельно.
- Прямое получение данных в Эффектах обычно означает, что вы не предзагружаете и не кэшируете данные. Например, если компонент размонтируется, а затем снова смонтируется, ему придётся снова получать данные.
- Это не очень удобно. При написании вызовов
fetchв таком виде, чтобы избежать ошибок, таких как состояния гонки, требуется довольно много шаблонного кода.
Этот список недостатков не специфичен для React. Он применим к получению данных при монтировании с любой библиотекой. Как и с маршрутизацией, получение данных не является тривиальной задачей, поэтому мы рекомендуем следующие подходы:
- Если вы используете фреймворк, используйте его встроенный механизм получения данных. Современные React-фреймворки имеют интегрированные механизмы получения данных, которые эффективны и не страдают от вышеупомянутых недостатков.
- В противном случае рассмотрите возможность использования или создания кэша на стороне клиента. Популярные решения с открытым исходным кодом включают React Query, useSWR и React Router 6.4+. Вы также можете создать собственное решение, в этом случае вы будете использовать Эффекты под капотом, но добавите логику, чтобы устранить дублирование запросов, кэширования ответов и избежать сетевые водопады (предзагружая данные или поднимая требования к данным к маршрутам).
Вы можете продолжать получать данные напрямую в Эффектах, если ни один из этих подходов вам не подходит.
Рассмотрим код, который отправляет событие аналитики при посещении страницы:
useEffect(() => {
logVisit(url); // Отправляет POST-запрос
}, [url]);В процессе разработки logVisit будет вызываться дважды для каждого URL, поэтому у вас может возникнуть желание попытаться это исправить. Мы рекомендуем оставить этот код как есть. Как и в предыдущих примерах, нет видимой пользователю разницы между выполнением его один раз и выполнением дважды. С практической точки зрения, logVisit не должен ничего делать в процессе разработки, потому что вы не хотите, чтобы логи с машин разработки искажали метрики в продакшен-режиме. Ваш компонент размонтируется каждый раз, когда вы сохраняете его файл, так что он все равно регистрирует дополнительные посещения в процессе разработки.
В продакшен-режиме не будет дублирующихся логов посещений.
Чтобы отладить события аналитики, которые вы отправляете, вы можете развернуть ваше приложение в тестовой среде (которая работает в продакшен-режиме) или временно отключить Strict Mode и его проверки размонтирования, действующие только в процессе разработки. Вы также можете отправлять аналитику из обработчиков событий изменения маршрута вместо эффектов. Для более точной аналитики наблюдатели пересечения могут помочь отслеживать, какие компоненты находятся в области видимости и как долго они остаются видимыми
Какая-то логика должна выполняться только один раз при запуске приложения. Вы можете поместить её вне ваших компонентов:
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 ? 'Размонтировать' : 'Установить'} компонент
</button>
{show && <hr />}
{show && <Playground />}
</>
);
}Сначала вы увидите три сообщения в консоли: Запланировать лог "a", Отменить лог "a" и снова Запланировать лог "a". Через три секунды также появится сообщение a. Как вы узнали ранее, дополнительная пара запланировать/отменить возникает из-за того, что React повторно монтирует компонент один раз в режиме разработки, чтобы убедиться, что вы правильно реализовали очистку.
Теперь измените ввод, чтобы он говорил abc. Если вы сделаете это достаточно быстро, вы увидите Запланировать лог "ab" сразу за ним Отменить лог "ab" и Запланировать лог "abc". 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>Добро пожаловать в {roomId}!</h1>;
}Давайте посмотрим, что именно происходит, когда пользователь перемещается по приложению.
Пользователь посещает <ChatRoom roomId="general" />. Давайте мысленно подставим 'general' на место roomId:
// 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>Добро пожаловать 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>Добро пожаловать в travel!</h1>;React обновляет DOM, чтобы изменить "Добро пожаловать в general" на "Добро пожаловать в 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 пропустит Эффект, если все его зависимости имеют те же значения, что и во время последнего рендеринга.
- Вы не можете "выбрать" свои зависимости. Они определяются кодом внутри Эффекта.
- Пустой массив зависимостей (
[]) соответствует "монтированию" компонента, т.е. добавлению его на экран. - В Strict Mode React монтирует компоненты дважды (только в режиме разработки!), чтобы протестировать ваши Эффекты.
- Если Эффект ломается из-за повторного монтирования, вам нужно реализовать функцию очистки.
- React вызовет вашу функцию очистки перед следующим выполнением Эффекта и во время размонтирования.
В этом примере форма рендерит компонент <MyInput />.
Используйте метод focus() элемента ввода, чтобы сделать так, чтобы MyInput автоматически получал фокус, когда он появляется на экране. Уже есть закомментированная реализация, но она не совсем работает. Разберитесь, почему она не работает, и исправьте это. (Если вы знакомы с атрибутом autoFocus, притворитесь, что его не существует: мы реализуем ту же функциональность с нуля.)
import { useEffect, useRef } from 'react';
export default function MyInput({ value, onChange }) {
const ref = useRef(null);
// TODO: Это не совсем работает. Исправьте это.
// ref.current.focus()
return (
<input
ref={ref}
value={value}
onChange={onChange}
/>
);
}import { useState } from 'react';
import MyInput from './MyInput.js';
export default function Form() {
const [show, setShow] = useState(false);
const [name, setName] = useState('Taylor');
const [upper, setUpper] = useState(false);
return (
<>
<button onClick={() => setShow(s => !s)}>{show ? 'Скрыть' : 'Показать'} форму</button>
<br />
<hr />
{show && (
<>
<label>
Введите ваше имя:
<MyInput
value={name}
onChange={e => setName(e.target.value)}
/>
</label>
<label>
<input
type="checkbox"
checked={upper}
onChange={e => setUpper(e.target.checked)}
/>
Сделать заглавной
</label>
<p>Привет, <b>{upper ? name.toUpperCase() : name}</b></p>
</>
)}
</>
);
}label {
display: block;
margin-top: 20px;
margin-bottom: 20px;
}
body {
min-height: 150px;
}Чтобы проверить, что ваше решение работает, нажмите "Показать форму" и убедитесь, что поле ввода получает фокус (подсвечивается и курсор помещается внутрь). Нажмите "Скрыть форму" и снова "Показать форму". Убедитесь, что поле ввода снова подсвечено.
MyInput должен получать фокус при монтировании, а не после каждого рендеринга. Чтобы проверить, что поведение правильное, нажмите "Показать форму", а затем многократно нажимайте на чекбокс "Сделать заглавной". Нажатие на чекбокс не должно фокусировать поле ввода выше.
Вызов ref.current.focus() во время рендеринга неверен, потому что это побочный эффект. Побочные эффекты должны либо находиться внутри обработчика событий, либо быть объявлены с помощью useEffect. В данном случае побочный эффект вызывается появлением компонента, а не каким-либо конкретным взаимодействием, поэтому имеет смысл поместить его в Эффект.
Чтобы исправить ошибку, оберните вызов ref.current.focus() в объявление Эффекта. Затем, чтобы гарантировать, что этот Эффект выполняется только при монтировании, а не после каждого рендеринга, добавьте к нему пустой массив [] зависимостей.
import { useEffect, useRef } from 'react';
export default function MyInput({ value, onChange }) {
const ref = useRef(null);
useEffect(() => {
ref.current.focus();
}, []);
return (
<input
ref={ref}
value={value}
onChange={onChange}
/>
);
}import { useState } from 'react';
import MyInput from './MyInput.js';
export default function Form() {
const [show, setShow] = useState(false);
const [name, setName] = useState('Taylor');
const [upper, setUpper] = useState(false);
return (
<>
<button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
<br />
<hr />
{show && (
<>
<label>
Введите ваше имя:
<MyInput
value={name}
onChange={e => setName(e.target.value)}
/>
</label>
<label>
<input
type="checkbox"
checked={upper}
onChange={e => setUpper(e.target.checked)}
/>
Сделать заглавной
</label>
<p>Привет, <b>{upper ? name.toUpperCase() : name}</b></p>
</>
)}
</>
);
}label {
display: block;
margin-top: 20px;
margin-bottom: 20px;
}
body {
min-height: 150px;
}Эта форма рендерит два компонента <MyInput />.
Нажмите "Показать форму" и обратите внимание, что второе поле автоматически получает фокус. Это происходит потому, что оба компонента <MyInput /> пытаются сфокусировать поле внутри. Когда вы вызываете focus() для двух полей ввода подряд, последнее всегда "выигрывает".
Предположим, вы хотите сфокусировать первое поле. Первому компоненту MyInput теперь передаётся булевый проп shouldFocus, установленный в true. Измените логику так, чтобы focus() вызывался только в том случае, если проп shouldFocus, полученный компонентом MyInput, равен true.
import { useEffect, useRef } from 'react';
export default function MyInput({ shouldFocus, value, onChange }) {
const ref = useRef(null);
// TODO: вызывайте focus() только если shouldFocus равно true.
useEffect(() => {
ref.current.focus();
}, []);
return (
<input
ref={ref}
value={value}
onChange={onChange}
/>
);
}import { useState } from 'react';
import MyInput from './MyInput.js';
export default function Form() {
const [show, setShow] = useState(false);
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [upper, setUpper] = useState(false);
const name = firstName + ' ' + lastName;
return (
<>
<button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
<br />
<hr />
{show && (
<>
<label>
Введите ваше имя:
<MyInput
value={firstName}
onChange={e => setFirstName(e.target.value)}
shouldFocus={true}
/>
</label>
<label>
Введите вашу фамилию:
<MyInput
value={lastName}
onChange={e => setLastName(e.target.value)}
shouldFocus={false}
/>
</label>
<p>Привет, <b>{upper ? name.toUpperCase() : name}</b></p>
</>
)}
</>
);
}label {
display: block;
margin-top: 20px;
margin-bottom: 20px;
}
body {
min-height: 150px;
}Чтобы проверить ваше решение, нажимайте "Показать форму" и "Скрыть форму" несколько раз. Когда форма появляется, только первое поле ввода должно получать фокус. Это происходит потому, что родительский компонент рендерит первое поле с shouldFocus={true}, а второе поле с shouldFocus={false}. Также проверьте, что оба поля ввода по-прежнему работают и вы можете вводить текст в оба из них.
Вы не можете объявлять Эффект по условию, но ваш Эффект может включать условную логику.
Поместите условную логику внутрь Эффекта. Вам нужно будет указать shouldFocus в качестве зависимости, потому что вы используете его внутри Эффекта. (Это означает, что если значение shouldFocus для какого-либо поля ввода изменится с false на true, оно получит фокус после монтирования.)
import { useEffect, useRef } from 'react';
export default function MyInput({ shouldFocus, value, onChange }) {
const ref = useRef(null);
useEffect(() => {
if (shouldFocus) {
ref.current.focus();
}
}, [shouldFocus]);
return (
<input
ref={ref}
value={value}
onChange={onChange}
/>
);
}import { useState } from 'react';
import MyInput from './MyInput.js';
export default function Form() {
const [show, setShow] = useState(false);
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [upper, setUpper] = useState(false);
const name = firstName + ' ' + lastName;
return (
<>
<button onClick={() => setShow(s => !s)}>{show ? 'Hide' : 'Show'} form</button>
<br />
<hr />
{show && (
<>
<label>
Введите ваше имя:
<MyInput
value={firstName}
onChange={e => setFirstName(e.target.value)}
shouldFocus={true}
/>
</label>
<label>
Введите вашу фамилию:
<MyInput
value={lastName}
onChange={e => setLastName(e.target.value)}
shouldFocus={false}
/>
</label>
<p>Привет, <b>{upper ? name.toUpperCase() : name}</b></p>
</>
)}
</>
);
}label {
display: block;
margin-top: 20px;
margin-bottom: 20px;
}
body {
min-height: 150px;
}Этот компонент Counter отображает счётчик, который должен увеличиваться каждую секунду. При монтировании он вызывает setInterval. Это приводит к тому, что onTick выполняется каждую секунду. Функция onTick увеличивает счётчик.
Однако вместо того, чтобы увеличиваться раз в секунду, он увеличивается дважды. Почему это происходит? Найдите причину ошибки и исправьте её.
Имейте в виду, что setInterval возвращает идентификатор интервала, который вы можете передать в clearInterval, чтобы остановить интервал.
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
function onTick() {
setCount(c => c + 1);
}
setInterval(onTick, 1000);
}, []);
return <h1>{count}</h1>;
}import { useState } from 'react';
import Counter from './Counter.js';
export default function Form() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(s => !s)}>{show ? 'Скрыть' : 'Показать'} счётчик</button>
<br />
<hr />
{show && <Counter />}
</>
);
}label {
display: block;
margin-top: 20px;
margin-bottom: 20px;
}
body {
min-height: 150px;
}Когда Strict Mode включён (как в песочницах на этом сайте), React монтирует каждый компонент заново один раз в режиме разработки. Это приводит к тому, что интервал настраивается дважды, и именно поэтому счётчик увеличивается дважды каждую секунду.
Однако поведение React не является причиной ошибки: ошибка уже существует в коде. Поведение React делает ошибку более заметной. Реальная причина в том, что этот эффект запускает процесс, но не предоставляет способ его очистки.
Чтобы исправить этот код, сохраните идентификатор интервала, возвращаемый setInterval, и реализуйте функцию очистки с помощью clearInterval:
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
function onTick() {
setCount(c => c + 1);
}
const intervalId = setInterval(onTick, 1000);
return () => clearInterval(intervalId);
}, []);
return <h1>{count}</h1>;
}import { useState } from 'react';
import Counter from './Counter.js';
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(s => !s)}>{show ? 'Скрыть' : 'Показать'} счётчик</button>
<br />
<hr />
{show && <Counter />}
</>
);
}label {
display: block;
margin-top: 20px;
margin-bottom: 20px;
}
body {
min-height: 150px;
}В режиме разработки React все равно заново смонтирует ваш компонент один раз, чтобы убедиться, что вы правильно реализовали очистку. Таким образом, будет вызов setInterval, сразу за которым последует clearInterval, и снова setInterval. В производственной версии будет только один вызов setInterval. Поведение, видимое пользователем, в обоих случаях будет одинаковым: счётчик увеличивается раз в секунду.
Этот компонент отображает биографию выбранного человека. Он загружает биографию, вызывая асинхронную функцию fetchBio(person) при монтировании и каждый раз, когда person изменяется. Эта асинхронная функция возвращает Promise, который в конечном итоге разрешается в строку. Когда получение данных завершено, она вызывает setBio, чтобы отобразить эту строку под выпадающим списком.
{/* not the most efficient, but this validation is enabled in the linter only, so it's fine to ignore it here since we know what we're doing */}
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
setBio(null);
fetchBio(person).then(result => {
setBio(result);
});
}, [person]);
return (
<>
<select value={person} onChange={e => {
setPerson(e.target.value);
}}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
<option value="Taylor">Taylor</option>
</select>
<hr />
<p><i>{bio ?? 'Загрузка...'}</i></p>
</>
);
}export async function fetchBio(person) {
const delay = person === 'Bob' ? 2000 : 200;
return new Promise(resolve => {
setTimeout(() => {
resolve('Персонаж этой биографии — ' + person);
}, delay);
})
}В этом коде есть ошибка. Начните с выбора "Alice". Затем выберите "Bob", а сразу после этого выберите "Taylor". Если вы сделаете это достаточно быстро, вы заметите эту ошибку: "Taylor" выбран, но в параграфе ниже написано "Персонаж этой биографии — Bob".
Почему это происходит? Исправьте ошибку внутри этого эффекта.
Если эффект асинхронно получает что-то, ему обычно требуется очистка.
Чтобы вызвать ошибку, события должны происходить в следующем порядке:
- Выбор
'Bob'запускаетfetchBio('Bob') - Выбор
'Taylor'запускаетfetchBio('Taylor') - Получение
'Taylor'завершается до получения'Bob' - Эффект от рендера
'Taylor'вызываетsetBio('Персонаж этой биографии — Taylor') - Получение
'Bob'завершается - Эффект от рендера
'Bob'вызываетsetBio('Персонаж этой биографии — Bob')
Вот почему вы видите биографию Боба, даже когда выбрана Тейлор. Ошибки такого рода называются гонки состояний, потому что две асинхронные операции "соревнуются" друг с другом, и они могут завершиться в неожиданном порядке.
Чтобы исправить эти гонки состояний, добавьте функцию очистки:
{/* not the most efficient, but this validation is enabled in the linter only, so it's fine to ignore it here since we know what we're doing */}
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
}
}, [person]);
return (
<>
<select value={person} onChange={e => {
setPerson(e.target.value);
}}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
<option value="Taylor">Taylor</option>
</select>
<hr />
<p><i>{bio ?? 'Загрузка...'}</i></p>
</>
);
}export async function fetchBio(person) {
const delay = person === 'Bob' ? 2000 : 200;
return new Promise(resolve => {
setTimeout(() => {
resolve('Персонаж этой биографии — ' + person);
}, delay);
})
}У каждого эффекта рендера есть своя переменная ignore. Изначально переменная ignore установлена в false. Однако, если эффект очищается (например, когда вы выбираете другого человека), его переменная ignore становится true. Таким образом, теперь не имеет значения, в каком порядке завершаются запросы. Только эффект последнего выбранного человека будет иметь ignore, установленный в false, поэтому он вызовет setBio(result). Прошлые эффекты были очищены, поэтому проверка if (!ignore) предотвратит их вызов setBio:
- Выбор
'Bob'запускаетfetchBio('Bob') - Выбор
'Taylor'запускаетfetchBio('Taylor')и очищает предыдущий эффект - Получение
'Taylor'завершается до получения'Bob' - Эффект от рендера
'Taylor'вызываетsetBio('Персонаж этой биографии — Taylor') - Получение
'Bob'завершается - Эффект от рендера
'Bob'ничего не делает, потому что его флагignoreбыл установлен вtrue
В дополнение к игнорированию результата устаревшего API-запроса, вы также можете использовать AbortController для отмены запросов, которые больше не нужны. Однако этого недостаточно, чтобы защититься от состояний гонки. После получения данных могут быть связаны дополнительные асинхронные шаги, поэтому использование явного флага, такого как ignore, является самым надёжным способом решения этой проблемы.