Возможно, вам не нужен Effect
Эффекты — это способ выйти за рамки парадигмы React. Они позволяют «выйти за пределы» React и синхронизировать компоненты с внешней системой, например виджетом, не связанным с React, сетью или DOM браузера. Если внешняя система не задействована (например, если вы хотите обновить состояние компонента при изменении каких-либо пропсов или состояния), Effect вам не нужен. Удаление ненужных эффектов сделает код проще для понимания, ускорит его выполнение и снизит вероятность ошибок.
Вы узнаете
- Почему и как удалять ненужные эффекты из компонентов
- Как кэшировать ресурсоёмкие вычисления без эффектов
- Как сбрасывать и корректировать состояние компонента без эффектов
- Как делиться логикой между обработчиками событий
- Какую логику следует перенести в обработчики событий
- Как сообщать родительским компонентам об изменениях
Как удалить ненужные эффекты
Есть два распространённых случая, когда эффекты не нужны:
- Эффекты не нужны для преобразования данных при рендеринге. Например, допустим, вы хотите отфильтровать список перед отображением. Возможно, вам захочется написать Effect, который обновляет переменную состояния при изменении списка. Однако это неэффективно. При обновлении состояния React сначала вызовет функции ваших компонентов, чтобы определить, что должно отображаться на экране. Затем React «зафиксирует» эти изменения в DOM, обновив экран. После этого React запустит эффекты. Если ваш Effect также сразу же обновит состояние, весь процесс начнётся заново! Чтобы избежать лишних проходов рендеринга, преобразуйте данные на верхнем уровне компонентов. Этот код будет автоматически выполняться при каждом изменении пропсов или состояния.
-
Эффекты не нужны для обработки действий пользователя. Например, допустим, вы хотите отправить POST-запрос
/api/buyи показать уведомление, когда пользователь покупает товар. В обработчике события клика по кнопке «Купить» вам точно известно, что произошло. К моменту запуска Effect вы уже не знаете, что сделал пользователь (например, на какую кнопку нажал). Поэтому действия пользователя обычно обрабатывают в соответствующих обработчиках событий.
Эффекты нужны для синхронизации с внешними системами. Например, можно написать Effect, который будет синхронизировать виджет jQuery с состоянием React. С помощью эффектов также можно получать данные: например, синхронизировать результаты поиска с текущим поисковым запросом. Имейте в виду, что современные фреймворки предоставляют более эффективные встроенные механизмы получения данных, чем написание эффектов непосредственно в компонентах.
Чтобы помочь вам выработать правильное представление, рассмотрим несколько распространённых конкретных примеров!
Обновление состояния на основе пропсов или состояния
Предположим, у вас есть компонент с двумя переменными состояния: firstName и lastName. Вы хотите вычислить из них fullName, объединив их. Кроме того, вы хотите, чтобы fullName обновлялось при изменении firstName или lastName. Первое, что может прийти вам в голову, — добавить переменную состояния fullName и обновлять её в Effect:
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, а затем сразу же происходит повторный рендеринг с обновлённым значением. Удалите переменную состояния и Effect:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
} Если что-то можно вычислить на основе имеющихся пропсов или состояния, не сохраняйте это в состоянии. Вместо этого вычисляйте значение во время рендеринга. Так код будет работать быстрее (вы избежите дополнительных каскадных обновлений), станет проще (вы удалите часть кода) и будет реже приводить к ошибкам (вы избежите ошибок, вызванных рассинхронизацией разных переменных состояния). Если этот подход для вас в новинку, в статье «Мышление в терминах React» объясняется, что следует хранить в состоянии.
Кэширование ресурсоёмких вычислений
Этот компонент вычисляет visibleTodos: берёт полученные через пропсы todos и фильтрует их в соответствии с пропсом filter. Возможно, вам захочется сохранить результат в состоянии и обновлять его с помощью Effect:
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]);
// ...
} Как и в предыдущем примере, это и не нужно, и неэффективно. Для начала удалите состояние и Effect:
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:
Примечание
Компилятор React может автоматически мемоизировать ресурсоёмкие вычисления, избавляя во многих случаях от необходимости вручную использовать 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 для этого есть параметр «Замедление ЦП».
Также обратите внимание, что измерения производительности в режиме разработки не будут максимально точными. (Например, если включён Strict Mode, вы увидите, что каждый компонент рендерится дважды, а не один раз.) Для получения наиболее точных данных соберите приложение для продакшена и протестируйте его на устройстве, похожем на устройства ваших пользователей.
Сброс всего состояния при изменении пропса
Этот компонент 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 и его дочерних компонентов, запустив весь процесс заново.
Для начала удалите Effect. Вместо этого скорректируйте состояние непосредственно во время рендеринга:
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);
}
// ...
} Сохранять информацию о предыдущих рендерингах таким способом может быть непросто, но это лучше, чем обновлять то же состояние в Effect. В приведённом выше примере setSelection вызывается непосредственно во время рендеринга. Сразу после выполнения с инструкцией return React повторно отрендерит компонент List. React ещё не отрендерил дочерние компоненты List и не обновил DOM, поэтому дочерние компоненты List пропустят рендеринг с устаревшим значением selection.
При обновлении компонента во время рендеринга React отбрасывает возвращённый JSX и сразу же повторяет рендеринг. Чтобы избежать очень медленных каскадных повторов, React разрешает обновлять во время рендеринга состояние только того же компонента. При обновлении состояния другого компонента во время рендеринга вы увидите ошибку. Условие вроде items !== prevItems необходимо, чтобы избежать циклов. Вы можете корректировать состояние таким образом, однако все остальные побочные эффекты (например, изменение DOM или установка таймеров) должны оставаться в обработчиках событий или эффектах, чтобы компоненты оставались чистыми.
Хотя этот подход эффективнее, чем Effect, большинству компонентов он тоже не нужен. Независимо от способа, корректировка состояния на основе пропсов или другого состояния усложняет понимание потока данных и отладку. Всегда проверяйте, нельзя ли вместо этого сбросить всё состояние с помощью ключа или вычислять всё во время рендеринга. Например, вместо сохранения (и сброса) выбранного элемента можно хранить ID выбранного элемента:
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() в обработчиках нажатия обеих кнопок кажется повторением кода, поэтому может возникнуть соблазн поместить эту логику в Effect:
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');
}
// ...
} Этот Effect не нужен. Скорее всего, он также приведёт к ошибкам. Например, допустим, ваше приложение «запоминает» содержимое корзины после перезагрузки страницы. Если добавить товар в корзину и обновить страницу, уведомление появится снова. Оно будет появляться при каждом обновлении страницы этого товара. Это происходит потому, что при загрузке страницы product.isInCart уже будет true, поэтому Effect выше вызовет showNotification().
Если вы не уверены, следует ли поместить код в Effect или обработчик события, спросите себя, почему этот код должен выполняться. Используйте эффекты только для кода, который должен выполняться потому, что компонент был показан пользователю. В этом примере уведомление должно появиться, потому что пользователь нажал кнопку, а не потому, что страница отобразилась! Удалите Effect и вынесите общую логику в функцию, вызываемую из обоих обработчиков событий:
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');
}
// ...
} Так вы и удалите ненужный Effect, и исправите ошибку.
Отправка 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-запрос аналитики следует оставить в Effect. Причина отправки события аналитики — отображение формы. (В режиме разработки запрос будет отправлен дважды, но здесь объясняется, как с этим справиться.)
Однако POST-запрос /api/register не связан с тем, что форма отобразилась. Вы хотите отправить запрос только в один конкретный момент: когда пользователь нажимает кнопку. Он должен выполняться только в ответ на это конкретное взаимодействие. Удалите второй Effect и перенесите этот 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 });
}
// ...
} Решая, поместить ли логику в обработчик события или Effect, прежде всего спросите себя, какого рода эта логика с точки зрения пользователя. Если она вызвана конкретным взаимодействием, оставьте её в обработчике события. Если она вызвана тем, что пользователь видит компонент на экране, оставьте её в Effect.
Цепочки вычислений
Иногда может возникнуть соблазн выстроить цепочку эффектов, каждый из которых корректирует часть состояния на основе другого состояния:
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.
В некоторых случаях вычислить следующее состояние непосредственно в обработчике события невозможно. Например, представьте форму с несколькими раскрывающимися списками, в которой варианты следующего списка зависят от выбранного значения предыдущего. Тогда цепочка эффектов уместна, поскольку вы синхронизируетесь с сетью.
Инициализация приложения
Некоторая логика должна выполняться только один раз при загрузке приложения.
Может возникнуть соблазн поместить её в Effect в компоненте верхнего уровня:
function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
} Однако вскоре вы обнаружите, что в режиме разработки она выполняется дважды. Это может привести к проблемам — например, возможно, будет аннулирован токен аутентификации, потому что функция не рассчитана на двукратный вызов. В целом ваши компоненты должны корректно работать при повторном монтировании. Это относится и к компоненту App верхнего уровня.
Хотя на практике в production компонент, возможно, никогда не будет монтироваться повторно, соблюдение одинаковых ограничений во всех компонентах упрощает перемещение и повторное использование кода. Если какая-то логика должна выполняться один раз при загрузке приложения, а не один раз при монтировании компонента, добавьте переменную верхнего уровня, чтобы отслеживать, выполнялась ли она уже:
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, отрендеренный на сервере, содержал полученный контент, а не индикатор загрузки) и избегать сетевых каскадов (чтобы дочерний компонент мог получать данные, не дожидаясь каждого родительского компонента).
Эти проблемы актуальны для любой библиотеки пользовательского интерфейса, а не только для 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://react.dev/learn/you-might-not-need-an-effect