Эффекты для синхронизации

Некоторые компоненты требуют синхронизации с внешними системами. Например, может возникнуть необходимость взаимодействовать с компонентом, не связанным с React, в зависимости от состояния вашего приложения, установить соединение с сервером или отправить данные аналитики при появлении компонента на экране. Эффекты позволяют выполнять код после рендеринга, обеспечивая синхронизацию компонента с системами вне React.

Вы узнаете

  • Что такое Эффекты
  • Чем Эффекты отличаются от событий
  • Как объявить Эффект в компоненте
  • Как избежать лишних перезапусков Эффектов
  • Почему в режиме разработки Эффекты запускаются дважды и как это исправить

Что такое Эффекты и чем Эффекты отличаются от событий?

Прежде чем перейти к Эффектам, вам нужно познакомиться с двумя типами логики внутри компонентов React:

  • Код рендеринга (подробнее Описание UI) находится на верхнем уровне вашего компонента. Здесь вы берёте пропсы и состояние, преобразуете их и возвращаете тот JSX, который вы хотите видеть на экране. Код рендеринга должен быть чистым. Как и математическая формула, он должен только вычислять результат, не выполняя других действий.

  • Обработчики событий (подробнее Добавление интерактивности) — это вложенные функции внутри ваших компонентов, которые выполняют действия, а не просто их вычисляют. Обработчики событий могут обновлять поля ввода, отправлять HTTP POST-запросы для покупки продукта или перенаправлять пользователя на другой экран. Обработчики событий содержат “побочные эффекты”, вызванные конкретными действиями пользователя (например, клик по кнопке или набор текста).

Иногда этого недостаточно. Рассмотрим компонент ChatRoom, который должен подключаться к серверу чата каждый раз, когда он появляется на экране. Подключение к серверу — это не чистое вычисление (это побочный эффект), поэтому его невозможно выполнить во время рендеринга компонента. Однако, не происходит и какого-то конкретного события, подобного клику, который отображал бы ChatRoom.

Эффекты позволяют указать побочные эффекты, вызванные самим рендерингом, а не конкретным событием. Отправка сообщения в чате является событием, потому что происходит после того, как пользователь нажимает на определённую кнопку. В то же время, установка соединения с сервером является Эффектом, так как она должна произойти независимо от того, какое взаимодействие вызвало появление компонента. Эффекты выполняются в конце процесса фиксации, после того как экран обновится. Это подходящий момент для синхронизации компонентов React с какой-либо внешней системой (например, сетью или сторонней библиотекой).

Note

Здесь и далее в тексте «Эффект», написанный с заглавной буквы, относится к приведенному выше определению, специфичному для React, то есть к побочному эффекту, вызванному рендерингом. Чтобы отличать его от общепрограммного концепта, мы будем называть последний «побочные эффекты».

Возможно, Эффект вам не нужен

Не торопитесь добавлять Эффекты в ваши компоненты. Помните, что Эффекты обычно используются для того, чтобы выйти за пределы вашего React-кода и синхронизироваться с внешними системами. К ним относятся API браузера, сторонние виджеты, сеть и тому подобное. Если ваш Эффект лишь устанавливает одно состояние на основании другого состояния, возможно, Эффект вам не нужен.

Как написать Эффект

Чтобы написать Эффект, следуйте трём шагам:

  1. Объявите Эффект. По умолчанию Эффект будет запускаться после каждой фазы фиксации.
  2. Укажите зависимости Эффекта. Большинство Эффектов должны перезапускаться только когда это необходимо, а не при каждом рендере. Например, анимация должна срабатывать только когда компонент появляется. Подключение к чату или отключение от него должно происходить только когда компонент появляется или исчезает, или когда чат меняется. Вы узнаете, как контролировать это устанавливая зависимости.
  3. Добавьте функцию очистки при необходимости. Некоторые Эффекты нуждаются в указании, как остановить, отменить или очистить то, что они делали. Например, “установка связи” нуждается “в разрыве связи”, “подписка” нуждается в “отписке”, а “запрос данных” может нуждаться в “отмене” или “игнорировании”. Вы узнаете, как делать это возвращая функцию очистки.

Давайте взглянем на каждый из этих шагов подробнее.

Шаг 1: Объявление Эффекта

Чтобы объявить Эффект в вашем компоненте, импортируйте 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"
      />
    </>
  );
}

Этот код некорректен, потому что он пытается взаимодействовать с 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"
      />
    </>
  );
}

В этом примере “внешней системой”, которую вы синхронизировали с состоянием React, было браузерное медиа API. Вы можете использовать похожий подход, чтобы обернуть устаревший код, не использующий React (например, плагины jQuery), в декларативные компоненты React.

Обратите внимание, что управление видеоплеером на практике гораздо сложнее. Вызов play() может не сработать, пользователь может воспроизводить или останавливать видео, используя встроенные элементы управления браузера, и так далее. Этот пример очень упрощён и неполон.

Pitfall

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

const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});

Эффекты запускаются как результат рендеринга. Установка состояния запускает рендеринг. Установка состояния немедленно в Эффекте — это как подключить розетку в саму себя. Эффект запускается, он устанавливает состояние, что вызывает повторный рендеринг, что снова устанавливает состояние, и так далее.

Эффекты обычно должны синхронизировать ваши компоненты с внешней системой. Если это не внешняя система и вы только хотите обновить одно состояние на основе другого состояния, возможно, вам не нужен Эффект.

Step 2: Укажите зависимости Эффекта

По умолчанию Эффекты запускаются после каждого рендеринга. Часто это не то, что вам нужно:

  • Иногда это медленно. Синхронизация с внешней системой не всегда происходит мгновенно, поэтому имеет смысл избегать этого процесса, если в этом нет необходимости. Например, вам не нужно переподключаться к серверу чата при каждом нажатии клавиши.
  • Иногда это неправильно. Например, вы не хотите запускать анимацию появления компонента при каждом нажатии клавиши. Анимация должна воспроизводиться только один раз, когда компонент появляется в первый раз.

Чтобы продемонстрировать проблему, вот предыдущий пример с несколькими вызовами 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"
      />
    </>
  );
}

Вы можете сказать 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"
      />
    </>
  );
}

Проблема в том, что код внутри вашего Эффекта зависит от пропса 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"
      />
    </>
  );
}

Массив зависимостей может содержать несколько элементов. React пропустит повторный запуск Эффекта только в том случае, если все указанные вами зависимости имеют точно такие же значения, как и во время предыдущего рендеринга. React сравнивает значения зависимостей, используя сравнение Object.is. См. справку по useEffect, чтобы получить подробную информацию.

Обратите внимание, что вы не можете “выбирать” свои зависимости. Вы получите ошибку линтинга, если указанные вами зависимости не соответствуют тому, что ожидает React на основе кода внутри вашего эффекта. Это помогает выявить многие ошибки в вашем коде. Если вы не хотите, чтобы какой-то код повторно выполнялся, измените сам код эффекта так, чтобы он не “нуждался” в этой зависимости.

Pitfall

Поведение без массива зависимостей и с пустым [] массивом зависимостей различается:

useEffect(() => {
// Это выполняется после каждого рендеринга
});

useEffect(() => {
// Это выполняется только при монтировании (когда компонент появляется)
}, []);

useEffect(() => {
// Это выполняется при монтировании *и также*, если a или b изменились с последнего рендеринга
}, [a, b]);

Мы внимательно рассмотрим, что означает “монтирование”, на следующем шаге.

Deep Dive

Почему 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(). Как сохранить подключение компонента, пока он отображается пользователю?

Начните с написания логики Эффекта:

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>;
}

Этот Эффект выполняется только при монтировании, поэтому вы могли бы ожидать, что "✅ Подключение..." будет выведено в консоль один раз. Тем не менее, если вы заглянете в консоль, то увидите, что "✅ Подключение..." выводится дважды. Почему это происходит?

Представьте, что компонент 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>;
}

Теперь вы получаете три лога в консоли в режиме разработки:

  1. "✅ Подключение..."
  2. "❌ Отключено."
  3. "✅ Подключение..."

Это правильное поведение в режиме разработки. Размонтируя ваш компонент, React проверяет, что переход на другой экран и обратно не сломает ваш код. Отключение, а затем повторное подключение — это именно то, что должно происходить! Когда вы правильно реализуете функцию очистки, не должно быть заметной разницы для пользователя между выполнением Эффекта один раз и его выполнением, очисткой и повторным выполнением. Пара дополнительных вызовов подключения/отключения возникает потому, что React проверяет ваш код на наличие ошибок в режиме разработки. Это нормально — не пытайтесь это устранить!

В продакшене вы увидите, что "✅ Подключение..." выводится только один раз. Размонтирование компонентов происходит только в режиме разработки, чтобы помочь вам обнаружить Эффекты, которые нуждаются в очистке. Вы можете отключить Strict Mode, чтобы отказаться от поведения в режиме разработки, но мы рекомендуем оставить его включенным. Это позволяет вам находить множество ошибок, подобных описанной выше.

Как управлять двойным срабатыванием Эффекта в процессе разработки?

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

Обычно ответ заключается в реализации функции очистки. Функция очистки должна останавливать или отменять то, что делал Эффект. Правило заключается в том, что пользователь не должен отличать выполнение Эффекта один раз (как в производственной среде) от последовательности настройка → очистка → настройка (как это происходит в режиме разработки).

Большинство Эффектов, которые вы будете писать, будут соответствовать одному из общих шаблонов ниже.

Pitfall

Не используйте рефы, чтобы предотвратить срабатывание Эффектов.

Распространённая ошибка при предотвращении двойного срабатывания Эффектов в режиме разработки — это использование ref, чтобы предотвратить выполнение Эффекта более одного раза. Например, вы могли бы “исправить” вышеупомянутую ошибку с помощью useRef:

const connectionRef = useRef(null);
useEffect(() => {
// 🚩 Это не исправит ошибку!!!
if (!connectionRef.current) {
connectionRef.current = createConnection();
connectionRef.current.connect();
}
}, []);

Это позволяет видеть ”✅ Подключение…” только один раз в режиме разработки, но не решает проблему.

Когда пользователь уходит со страницы, соединение всё равно не закрывается, и когда он возвращается, создаётся новое соединение. По мере того как пользователь перемещается по приложению, соединения будут накапливаться, так же как и до “исправления”.

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

Смотрите примеры ниже, чтобы понять, как обрабатывать типичные шаблоны.

Управление не-React виджетами

Иногда необходимо добавить пользовательские виджеты, которые не написаны на 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`);
// ...

Это не только улучшит опыт разработки, но и сделает ваше приложение более отзывчивым. Например, пользователь, нажимающий кнопку Назад, не будет ждать, пока данные загрузятся снова, потому что они будут кэшированы. Вы можете либо создать такой кэш самостоятельно, либо использовать одно из множества альтернативных решений для ручного получения данных в Эффектах.

Deep Dive

Какие существуют хорошие альтернативы получению данных в Эффектах?

Запись вызовов 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'. Другими словами, Эффекты из каждого рендера изолированы друг от друга. Если вам интересно, как это работает, вы можете прочитать о замыканиях.

Deep Dive

Каждый рендер имеет свои собственные Эффекты

Вы можете рассматривать 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 будет повторно монтировать Эффекты каждый раз, когда вы сохраняете файл в режиме разработки. Оба этих поведения доступны только в режиме разработки.

Recap

  • В отличие от событий, Эффекты вызываются самим рендерингом, а не конкретным взаимодействием.
  • Эффекты позволяют синхронизировать компонент с какой-либо внешней системой (API третьих сторон, сеть и т.д.).
  • По умолчанию Эффекты выполняются после каждого рендеринга (включая начальный).
  • React пропустит Эффект, если все его зависимости имеют те же значения, что и во время последнего рендеринга.
  • Вы не можете “выбрать” свои зависимости. Они определяются кодом внутри Эффекта.
  • Пустой массив зависимостей ([]) соответствует “монтированию” компонента, т.е. добавлению его на экран.
  • В Strict Mode React монтирует компоненты дважды (только в режиме разработки!), чтобы протестировать ваши Эффекты.
  • Если Эффект ломается из-за повторного монтирования, вам нужно реализовать функцию очистки.
  • React вызовет вашу функцию очистки перед следующим выполнением Эффекта и во время размонтирования.

Challenge 1 of 4:
Сфокусировать поле при монтировании

В этом примере форма рендерит компонент <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}
    />
  );
}

Чтобы проверить, что ваше решение работает, нажмите “Показать форму” и убедитесь, что поле ввода получает фокус (подсвечивается и курсор помещается внутрь). Нажмите “Скрыть форму” и снова “Показать форму”. Убедитесь, что поле ввода снова подсвечено.

MyInput должен получать фокус при монтировании, а не после каждого рендеринга. Чтобы проверить, что поведение правильное, нажмите “Показать форму”, а затем многократно нажимайте на чекбокс “Сделать заглавной”. Нажатие на чекбокс не должно фокусировать поле ввода выше.