Spec-Zone.ru › React 18

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

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

Вы узнаете

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

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

Существует два распространенных случая, в которых вам не нужны эффекты:

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

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

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

Обновление состояния на основе свойств или состояния

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

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

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

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

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  const fullName = firstName + ' ' + lastName;
  // ...
}

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

Кэширование дорогостоящих вычислений

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

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

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

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

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ This is fine if getFilteredTodos() is not slow.
  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(() => {
    // ✅ Does not re-run unless todos or filter change
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

Или, написанный в одну строку:

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ Does not re-run getFilteredTodos() unless todos or filter change
  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. Если общее зарегистрированное время составляет значительную величину (например, 1ms или больше), возможно, имеет смысл мемоизировать это вычисление. Как эксперимент, вы можете обернуть вычисление в useMemo, чтобы проверить, не уменьшилось ли общее зарегистрированное время для этого взаимодействия или нет:

console.time('filter array');
const visibleTodos = useMemo(() => {
  return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed
}, [todos, filter]);
console.timeEnd('filter array');

useMemo не ускорит первую отрисовку. Это помогает пропустить ненужную работу при обновлениях.

Запомните, что ваша машина, скорее всего, быстрее, чем у ваших пользователей, поэтому неплохо проверить производительность с искусственным замедлением. Например, Chrome предлагает опцию «Сужение производительности ЦП» для этого.

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

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

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

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

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

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

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

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

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

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

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

Настройка части состояния при изменении свойства

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

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

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

  // 🔴 Avoid: Adjusting state on prop change in an 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);

  // Better: Adjust the state while rendering
  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 или установка таймаутов) должны оставаться в обработчиках событий или эффектах, чтобы сохранять компоненты чистыми.

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

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: Calculate everything during rendering
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

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

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

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

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an Effect
  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 }) {
  // ✅ Good: Event-specific logic is called from event handlers
  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('');

  // ✅ Good: This logic should run because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Event-specific logic inside an Effect
  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('');

  // ✅ Good: This logic runs because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: Event-specific logic is in the event handler
    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);

  // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
  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);

  // ✅ Calculate what you can during rendering
  const isGameOver = round > 5;

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

    // ✅ Calculate all the next state in the event handler
    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() {
  // 🔴 Avoid: Effects with logic that should only ever run once
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

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

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

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ Only runs once per app load
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

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

if (typeof window !== 'undefined') { // Check if we're running in the browser.
   // ✅ Only runs once per app load
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

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

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

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

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

  // 🔴 Avoid: The onChange handler runs too late
  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) {
    // ✅ Good: Perform all updates during the event that caused them
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

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

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

  // ...
}

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

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

// ✅ Also good: the component is fully controlled by its parent
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();
  // 🔴 Avoid: Passing data to the parent in an Effect
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

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

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ Good: Passing data down to the child
  return <Child data={data} />;
}

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

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

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

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

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  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() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

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

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

Загрузка данных

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

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

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    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 отображает список задач. Когда галочка «Показать только активные задачи» отмечена, завершенные задачи не отображаются в списке. Независимо от того, какие задачи видны, подвал отображает количество задач, которые ещё не выполнены.

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

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

© 2013–present Facebook Inc.
Licensed under the Creative Commons Attribution 4.0 International Public License.
https://18.react.dev/learn/you-might-not-need-an-effect

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API