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

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

Вы изучите

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

Как удалить ненужные эффекты

Есть два распространенных случая, когда эффекты не нужны:

  • Вам не нужны Эффекты чтобы трансформировать данные для рендера. Например, для фильтра списка перед тем, как отобразить его. Это не совсем эффективно. Когда вы обновляете состояние, React сначала вызовет функции вашего компонента для расчета того, что должно быть на экране. Затем, React “фиксирует” текущие изменения в DOM обновляя экран, и уже после перечисленного выполнит Эффекты. Если Эффект еще и изменяет состояние компонента, то весь процесс начнётся заново! Чтобы избежать ненужных фаз рендеринга, трансформируйте все данные в начале ваших компонентов. Этот код будет автоматически выполнен повторно как только изменятся пропсы или состояние.
  • Вам не нужны Эффекты для обработчиков событий. Допустим вы хотите отправить POST-запрос на /api/buy и показать уведомление, как только пользователь приобретёт товар. Вы точно знаете что произошло в обработчике событий кнопки “Купить”. К моменту выполнения эффекта вы не знаете, что сделал пользователь (например, какая кнопка была нажата). Вот почему предпочтительно обрабатывать пользовательские события в соответствующих обработчиках.

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

Чтобы помочь вам развить интуицию, давайте рассмотрим несколько распространенных конкретных примеров!

Обновление состояния на основе пропсов или состояния компонента

Предположим, есть компонент с двумя переменными состояния: firstName и lastName. Вы хотите вычислить fullName, объединив их. Более того, вы хотите, чтобы fullName обновлялся при изменении firstName или lastName. Может возникнуть мысль добавить переменную состояния fullName и обновлять её с помощью эффекта:

function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');

// 🔴 Избегайте: избыточное состояние и ненужный эффект
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}

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

function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Лучше: значение вычислено во время рендера
const fullName = firstName + ' ' + lastName;
// ...
}

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

Кэширование затратных вычислений

Этот компонент вычисляет visibleTodos, принимая todos в качестве пропсов, и фильтрует их согласно пропсу filter. Вам может показаться заманчивым хранить результат в состоянии и обновлять его с помощью эффекта:

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');

// 🔴 Избегайте: избыточное состояние и ненужный эффект
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);

// ...
}

Как и в примере выше, этот код неэффективен. Для начала удалите ненужное состояние и эффект:

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Хорошо, если функция getFilteredTodos() не медленная.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}

Как правило, это хорошо работающий код! Но иногда getFilteredTodos() может быть медленной или у вас может быть много todos. В таком случае лучше не пересчитывать getFilteredTodos() если одна из несвязанных переменных изменилась, например, newTodo.

Вы можете кэшировать (или “мемоизировать”) дорогое вычисление обернув его в useMemo хук:

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ Не вызовется повторно, пока todos или filter не изменятся
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}

Альтернативная запись в одну строку:

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Не вызывает повторно getFilteredTodos() пока todos или filter не изменятся
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}

Этот код говорит React, что вы не хотите чтобы внутренняя функция вызывалась пока todos или filter не изменятся. React запомнит результат выполнения getFilteredTodos() из первого рендера. Во время следующего, он проверит если todos или filter отличаются. Если их значение не изменилось, useMemo вернёт последний “мемоизированный” результат. Но если значения отличаются, React вызовет внутреннюю функцию повторно (и снова запомнит результат).

Функции обёрнутые в useMemo выполняются во время рендеринга, поэтому это работает только для чистых вычислений.

Глубокое Погружение

Как определить, что вычисление дорогостоящее?

Как правило, если вы не создаете или не перебираете тысячи объектов, скорее всего, это не дорого. Для уверенности, добавьте запись в консоль для измерения времени, затраченного на выполнение части кода:

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

Выполните действие, которое хотите измерить (например, ввод текста в поле ввода). Вы увидите записи вроде filter array: 0.15ms в консоли. Если общее указанное время в журнале, составляет значительную сумму (1 мс или больше), имеет смысл закэшировать этот расчет. В качестве эксперимента оберните процесс в useMemo, чтобы проверить, уменьшилось ли общее время:

console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // Повторного вызова не произойдет, если filter и todos не изменились
}, [todos, filter]);
console.timeEnd('filter array');

useMemo не ускорит первый рендер, но он спасёт от ненужных выполнений при обновлениях.

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

Так же учитывайте то, что измерение производительности в режиме разработки не покажет самых точных результатов. (Например, в строгом режиме, вы заметите, что компоненты рендерятся дважды) Чтобы получить наиболее точные результаты, соберите приложение в продакшен-режиме и протестируйте его на устройстве, аналогичном тому, которым пользуются ваши пользователи.

Сброс состояния при изменении пропсов

Компонент ProfilePage получает userId через пропсы. На страннице находится поле ввода для комментария и используется состояние comment для сохранения его значения. Как-то раз вы заметили проблему: во время навигации с одного профиля на другой, состояние comment не сбрасывается и в результате, очень просто допустить ошибку случайно опубликовав комментарий в профиле не того пользователя. Чтобы исправить проблему, покажется хорошей идеей очистить состояние comment при изменении userId:

export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');

// 🔴 Избегайте: Сброс состояния при изменении пропсов в Эффекте
useEffect(() => {
setComment('');
}, [userId]);
// ...
}

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

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

export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}

function Profile({ userId }) {
// ✅ Это и любое другое состояние сбросится автоматически как только key изменится.
const [comment, setComment] = useState('');
// ...
}

Обычно React сохраняет состояние, когда один и тот же компонент отображается в одном месте. Передавая userId в качестве ключа key компоненту Profile, вы как бы просите React рассматривать два компонента Profile с разными userId как два разных компонента, которые не должны делить между собой никакого состояния. Всякий раз, когда key (установленный на userId) меняется, React обновит DOM и сбросит состояние компонента Profile и всех его дочерних. Теперь поле comment будет автоматически очищаться при переходе между профилями.

Обратите внимание, что в данном примере только внешний компонент ProfilePage экспортируется и виден другим файлам в проекте. Компоненты, которые рендерят ProfilePage, не должны передавать ему ключ: они передают userId в качестве обычного пропса. Тот факт, что ProfilePage передает его в качестве ключа key внутреннему компоненту Profile, является деталью реализации.

Изменение части состояния при изменении пропсов

Иногда при изменении пропсов хочется сбросить часть состояния, но не всего.

Компонент List получает список items в качестве пропсов и сохраняет выбранный элемент в переменной состояния selection. Вы захотели сбрасывать значение selection каждый раз, когда свойство items получает другой массив:

function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);

// 🔴 Избегайте: Изменение состояния при изменении свойства в Effect
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}

Пример выше так же не идеален. Каждый раз, когда items меняются, компонент List и его дочерние компоненты будут рендериться со старым значением selection. Затем React обновит DOM и выполнит Эффекты. В конце концов, вызов setSelection(null) вызовет еще один рендер List и его дочерних компонентов, перезапуская весь этот процесс снова.

Начните с удаления Эффекта. Вместо него, изменяйте состояние напрямую во время рендеринга:

function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);

// Уже лучше: состояние изменяется во время рендеринга
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}

Хранение информации из предыдущих рендеров может быть сложным для понимания, но этот способ лучше, чем обновление одного и того же состояния в Эффекте. В приведенном выше примере setSelection вызывается непосредственно во время отрисовки. React отрендерит List сразу же после выхода через return. React еще не отрисовал дочерние элементы List и не обновил DOM, поэтому это позволяет дочерним элементам List пропустить рендеринг так как значение selection не меняется.

Когда вы обновляете компонент во время рендеринга, React отбрасывает возвращаемый JSX и тут же повторяет рендеринг. Чтобы избежать очень медленных каскадных повторных попыток, React позволяет вам обновлять состояние только текущего компонента во время рендеринга. Если вы обновите состояние другого компонента во время рендеринга, вы заметите ошибку. Условие вроде items !== prevItems необходимо, чтобы избежать зацикливания. Таким образом можно изменить не только состояние, но любые другие побочные эффекты (например, изменение DOM или установка таймеров) должны оставаться в обработчиках событий или эффектах, чтобы сохранить чистоту компонентов.

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

function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Лучше: Вычисляйте всё во время рендеринга
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}

Теперь нет необходимости “настраивать” состояние вообще. Если элемент с выбранным ID находится в списке, он остается выбранным. Если его нет, selection, вычисленный во время рендеринга, будет равен null, потому что соответствующий элемент не был найден. Это поведение отличается, но, возможно, лучше, так как большинство изменений в items сохраняют выбор.

Обмен логикой между обработчиками событий

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

function ProductPage({ product, addToCart }) {
// 🔴 Избегайте: специфическая логика ивента внутри Эффекта
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);

function handleBuyClick() {
addToCart(product);
}

function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}

Этот Эффект лишний. Он также, скорее всего, вызовет ошибки. Представьте, что приложение “запоминает” корзину покупок между перезагрузками страницы. Если вы добавите товар в корзину один раз и обновите страницу, уведомление появится снова. Оно будет продолжать появляться каждый раз, когда вы обновляете страницу товара. Это происходит потому, что product.isInCart будет true при загрузке страницы, и вышеуказанный Эффект вызовет showNotification().

Когда есть сомнения, должен ли какой-то код быть в Эффекте или в обработчике событий, спросите себя, почему этот код должен выполняться. Используйте Эффекты только для кода, который должен выполняться потому что компонент был показан пользователю. В данном примере уведомление должно появляться потому, что пользователь нажал на кнопку, а не потому что страница была загружена! Удалите Эффект и поместите общую логику в функцию, вызываемую из обоих обработчиков событий:

function ProductPage({ product, addToCart }) {
// ✅ Лучше: специфическая ивент-логика вызывается из обработчиков.
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}

function handleBuyClick() {
buyProduct();
}

function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}

Этот код удаляет ненужный Эффект, и исправляет ошибку.

Отправка POST-запроса

Представим, что компонент Form делает два типа POST-запросов. Он отправляет событие аналитики при монтировании. А когда вы заполняете форму и нажимаете кнопку «Отправить», он отправит POST-запрос на конечную точку /api/register:

function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

// ✅ Хорошо: Этот код должен сработать когда компонент отрендерится
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);

// 🔴 Излишне: специфическая логика ивента внутри Эффекта
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);

function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}

Давайте применим те же критерии, что и в предыдущем примере.

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

Но POST-запрос /api/register не вызывается с рендерингом формы. Вы хотели бы, чтобы запрос происходил только в один конкретный момент времени: когда пользователь нажимает кнопку. Это должно произойти только при этом конкретном взаимодействии. Удалите второй Эффект и переместите POST-запрос в обработчик событий:

function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

// ✅ Хорошо: Эта код должен сработать когда компонент отрендерился
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);

function handleSubmit(e) {
e.preventDefault();
// ✅ Хорошо: специфическая ивент-логика вызывается из обработчиков.
post('/api/register', { firstName, lastName });
}
// ...
}

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

Цепочки вычислений

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

function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);

// 🔴 Избегайте: Цепочки Эффектов, которые корректируют состояние в зависимости от друг друга
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);

useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);

useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);

useEffect(() => {
alert('Good game!');
}, [isGameOver]);

function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}

// ...

У этого кода две проблемы.

Первая заключается в том, что это очень неэффективно: компонент (и его дочерние элементы) должны перерисовываться между каждым вызовом set в цепочке. В приведенном выше примере, в худшем случае (setCard → рендер → setGoldCardCount → рендер → setRound → рендер → setIsGameOver → рендер) происходят три ненужные перерисовки дерева.

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

В этом случае лучше вычислять то, что можно, во время рендеринга, и корректировать состояние в обработчике событий:

function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);

// ✅ Вычисление во время рендеринга
const isGameOver = round > 5;

function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}

// ✅ Вычисление следующего состояния в обработчике событий
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}

// ...

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

Помните, что внутри обработчиков событий состояние ведет себя как снимок. К примеру, даже после вызова setRound(round + 1), переменная round будет отражать значение на момент нажатия пользователем кнопки. Если вам нужно использовать следующее значение для расчётов, определите его вручную, например, const nextRound = round + 1.

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

Инициализация приложения

Некоторая логика должна выполняться только один раз при загрузке приложения.

Может показаться хорошей идеей разместить ее в эффекте в компоненте верхнего уровня:

function App() {
// 🔴 Избегайте: в эффекте логика которая выполняются один раз за всё время
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}

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

Хотя на практике он может никогда не быть смонтирован заново в продакшен-режиме, соблюдение одних и тех же ограничений во всех компонентах упрощает их перемещение и повторное использование кода. Если некоторая логика должна выполняться один раз при загрузке приложения, а не один раз при монтировании компонента, добавьте переменную верхнего уровня для отслеживания того, была ли она уже выполнена:

let didInit = false;

function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Будут вызваны один раз при загрузке приложения
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}

Так же можно сделать вызов во время инициализации модуля, до того как приложение отрендерится:

if (typeof window !== 'undefined') { // Проверка на то, что код выполняется в браузере.
// ✅ Выполнится один раз с загрузкой приложения
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ...
}

Код на верхнем уровне выполняется один раз при импорте вашего компонента, даже если он не будет отображаться. Чтобы избежать замедления или неожиданного поведения при импорте произвольных компонентов, не злоупотребляйте этим паттерном. Держите логику инициализации приложения в модулях корневых компонентов, таких как App.js, или в точке входа вашего приложения.

Уведомление родительских компонентов об изменении состояния

Допустим, вы пишете компонент Toggle с внутренним состоянием isOn, которое может быть либо true, либо false. Есть несколько способов переключить его (кликом или перетаскиванием). Вы хотите уведомить родительский компонент при каждом изменении внутреннего состояния Toggle, поэтому вы “пробрасываете” событие onChange и вызываете его из эффекта:

function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);

// 🔴 Избегайте: Событие onChange выполнится слишком поздно
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])

function handleClick() {
setIsOn(!isOn);
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}

// ...
}

Как и ранее, это не идеально. Toggle сначала обновит свое состояние, и React обновит экран. Затем React выполняет эффект, который вызывает функцию onChange, переданную от родительского компонента и родительский компонент обновит свое собственное состояние, начиная еще один цикл рендеринга. Было бы лучше сделать все за один проход.

Удалите эффект и вместо этого обновите состояние обоих компонентов в одном обработчике событий:

function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);

function updateToggle(nextIsOn) {
// ✅ Лучше: Выполнение всех обновлений во время события, которое их вызвало
setIsOn(nextIsOn);
onChange(nextIsOn);
}

function handleClick() {
updateToggle(!isOn);
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}

// ...
}

В этом подходе, компонент Toggle, и его родительский компонент обновляют свое состояние во время события. React группирует обновления из разных компонентов, поэтому будет только один цикл рендеринга.

Возможно, вы даже сможете удалить полностью состояние и вместо этого получать isOn из родительского компонента:

// ✅ Тоже хорошо: компонент полностью контролируется родителем
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}

// ...
}

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

Передача данных родителю

Компонент Child получает некоторые данные, а затем передает их компоненту Parent в эффекте:

function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 Избегайте: Передача данных из родителя внутрь Эффекта
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}

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

function Parent() {
const data = useSomeAPI();
// ...
// ✅ Лучше: Передача данных вниз по цепочке – дочернему компоненту
return <Child data={data} />;
}

function Child({ data }) {
// ...
}

Это проще и делает поток данных предсказуемым: данные передаются сверху вниз от родителя к дочернему элементу.

Подписка на внешнее хранилище

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

function useOnlineStatus() {
// Не идеально: Ручная подписка на внешнее состояние внутри эффекта.
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}

updateState();

window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

В этом примере компонент подписывается на внешнее хранилище данных (в данном случае API браузера navigator.onLine). Поскольку этот API отсутствует на сервере (поэтому его нельзя использовать для начального HTML), изначально состояние устанавливается в true. Когда значение этого хранилища данных меняется в браузере, компонент обновляет свое состояние.

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

function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}

function useOnlineStatus() {
// ✅ Лучше: Подписка на внешнее хранилище данных с помощью встроенного хука
return useSyncExternalStore(
subscribe, // React не будет подписываться заново, пока передаётся та же функция
() => navigator.onLine, // Как получить значение на клиенте
() => true // Как получить значение на сервере
);
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

Этот подход менее подвержен ошибкам, чем ручная синхронизация изменяемых данных с состоянием React при помощи эффекта. Как правило вы создадите пользовательский хук, такой же как useOnlineStatus() выше, чтобы не повторять этот код в отдельных компонентах. Узнайте больше о подписке на внешние хранилища из компонентов React.

Получение данных

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

function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);

useEffect(() => {
// 🔴 Избегайте: Получение данных без сбрасывающей функции
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);

function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

Вам не нужно перемещать получение данных в обработчик событий.

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

Неважно, откуда берутся page и query. Пока этот компонент видим, вы хотите синхронизировать results с данными из сети для текущих page и query. Вот для чего этот эффект.

Но код выше содержит ошибку. Представьте, что вы быстро набираете "hello". В этом случае query изменится с "h" на "he", "hel", "hell" и "hello". Это запустит отдельные запросы на получение данных, и нет гарантии, в каком порядке ответы придут. Например, ответ "hell" может прийти после ответа "hello". Так как последним вызовется setResults(), то будут отображены неправильные результаты поиска. Это называется “состоянием гонки”: два разных запроса “соревновались” друг с другом и пришли в другом порядке, не в том, что вы ожидали.

Чтобы исправить состояние гонки, вам нужно добавить функцию очистки, чтобы игнорировать устаревшие ответы:

function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);

function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

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

Управление состоянием гонки - не единственная сложность при реализации получения данных. Вам также может потребоваться кэширование ответов (чтобы пользователь мог нажать Назад и сразу увидеть предыдущий результат), как получать данные на сервере (чтобы начальный HTML, отрисованный сервером, содержал полученное содержимое, а не индикатор загрузки) и как избежать сетевых водопадов (чтобы дочерний компонент мог получать данные без ожидания каждого родительского компонента).

Эти проблемы относятся ко всем UI-библиотекам, а не только к React. Решение их не является тривиальным, поэтому современные фреймворки предоставляют более эффективные встроенные механизмы получения данных, чем получение данных с помощью эффектов.

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

function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);

function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}

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

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

Подведение итогов

  • Если вы можете вычислить что-то во время рендера, вам не нужен эффект.
  • Чтобы кэшировать дорогостоящие вычисления, добавьте useMemo вместо useEffect.
  • Чтобы сбросить состояние всего дерева компонентов, передайте другой key.
  • Чтобы сбросить определенную часть состояния в ответ на изменение пропса, установите его во время рендеринга.
  • Код, который запускается после того, как компонент отображён, должен быть в эффектах, остальное должно быть в событиях.
  • Если вам нужно обновить состояние нескольких компонентов, лучше сделать это во время одного события.
  • Всякий раз, когда вы пытаетесь синхронизировать переменные состояния в разных компонентах, рассмотрите возможность поднятия состояния.
  • Вы можете получать данные с помощью эффектов, но вам нужно реализовать очистку, чтобы избежать состояний гонки.

Испытание 1 из 4:
Трансформация данных без использования эффектов

Ниже, компонент TodoList отображает список задач. Когда установлен флажок “Show only active todos”, завершенные задачи не отображаются в списке. Независимо от того, какие задачи видимы, нижняя часть страницы отображает количество задач, которые еще не завершены.

Упростите этот компонент, удалив все ненужные состояния и эффекты.

import { useState, useEffect } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [activeTodos, setActiveTodos] = useState([]);
  const [visibleTodos, setVisibleTodos] = useState([]);
  const [footer, setFooter] = useState(null);

  useEffect(() => {
    setActiveTodos(todos.filter(todo => !todo.completed));
  }, [todos]);

  useEffect(() => {
    setVisibleTodos(showActive ? activeTodos : todos);
  }, [showActive, todos, activeTodos]);

  useEffect(() => {
    setFooter(
      <footer>
        {activeTodos.length} todos left
      </footer>
    );
  }, [activeTodos]);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      {footer}
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}