3.12.4 Хук эффекта
Хуки доступны в версии React 16.8. Они позволяют использовать состояние и другие функции React, освобождая от необходимости писать класс.
Побочные эффекты можно выполнять в компонентах-функциях используя хука эффекта:
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
// Похож на componentDidMount и componentDidUpdate:
useEffect(() => {
// Обновляем название докуммента, используя API браузера
document.title = `Вы нажали ${count} раз`;
});
return (
<div>
<p>Вы нажали {count} раз</p>
<button onClick={() => setCount(count + 1)}>
Нажми меня
</button>
</div>
);
}
Этот фрагмент кода основан на примере счетчика из предыдущего раздела. Однако мы добавили в него новую функцию: мы устанавливаем название документа, содержащее колличество нажатий.
Извлечение данных, настройка подписки и ручное изменение DOM в компонентах React - все это примеры побочных эффектов. Возможно, вы выполняли такие действия в своих компонентах, не зная, вероятно, что они так называются.
Внимание!
Если вы знакомы с методами ЖЦ компонента-класса, вы можете представлять себе хукuseEffect
как комбинацию componentDidMount
, componentDidUpdate
и componentWillUnmount
.
Компоненты React имеют два основных вида побочных эффектов: требующие очистки, и не требующие. Давайте разберём это различие более подробно.
Иногда мы хотим выполнить дополнительный код после того, как React обновил DOM. Сетевые запросы, ручные мутации DOM и логирование - типичные примеры эффектов, которые не требуют очистки. Мы можем выполнить какой-либо из них и забыть об этом. Давайте сравним, как классы и хуки позволяют нам производить такие побочные эффекты.
3.12.4.1.1 Пример с использованием класса
В компонентах-классах React метод render
не должен выполнять побочных
эффектов - слишком рано. Наши эффекты следует выполнять после того,
как React обновит DOM.
Вот почему в классах React мы помещаем побочные эффекты в методы
componentDidMount
и componentDidUpdate
. Теперь вернёмся к нашему примеру.
У нас есть компонент-класс со счётчиком, который обновляет название документа
сразу же после того, как React изменяет DOM:
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
componentDidMount() {
document.title = `Вы нажали ${this.state.count} раз`;
}
componentDidUpdate() {
document.title = `Вы нажали ${this.state.count} раз`;
}
render() {
return (
<div>
<p>Вы нажали {this.state.count} раз</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Кликни меня
</button>
</div>
);
}
}
Заметьте, как в классе нам приходится дублировать код в этих двух методах ЖЦ.
Так происходит потому, что нам обычно нужно выполнить один и тот же побочный эффект независимо от того, был ли компонент монтирован или обновлен. Концептуально, мы хотим, чтобы это происходило после каждой отрисовки, но у классов нет такого метода. Мы могли бы вынести код в отдельный метод, но нам все равно пришлось бы вызывать его в двух местах.
Давайте посмотрим, как можно сделать то же самое с хуком useEffect
.
3.12.4.1.2 Пример с использованием хука
Мы уже видели этот пример выше. Давайте рассмотрим его более подробно:
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Вы нажали ${count} раз`;
});
return (
<div>
<p>Вы нажали {count} раз</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
Что делает useEffect? Используя этот хук, вы сообщаете React, что ваш компонент должен что-то делать после отрисовки. React запомнит переданную вами функцию (мы будем называть ее «эффектом») и вызовет ее после обновления DOM. В нашем случае мы устанавливаем название документа. Кроме этого мы можем извлекать данные или вызывать любой другой императивный API.
Почему useEffect вызывается внутри компонента? Вызывая useEffect
внутри компонента, мы получаем доступ к переменной count
состояния счетчика
(или любым другим свойствам) прямо из эффекта. Нам не нужен специальный API
для её чтения - она уже находится в области видимости функции. Хуки охватывают
JavaScript-замыкания. Это позволяет обойтись без специального React API:
сам JavaScript предоставляет решение.
Запускается ли useEffect после каждой отрисовки? Да! По умолчанию он запускается как после первой отрисовки, так и после каждого последующего обновления. (Позже мы поговорим о том, как это можно кастомизировать.) Вместо того, чтобы мыслить в терминах «монтирования» и «обновления», можно просто представлять, что эффекты происходят «после отрисовки». React гарантирует, что DOM будет обновлен к моменту запуска эффектов.
3.12.4.1.3 Детальный разбор
Узнав больше об эффектах, этот код становится понятней:
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Вы нажали ${count} раз`;
});
Сначала мы объявляем переменную состояния count
, а затем говорим React,
что нам нужно использовать эффект. Мы передаем функцию, которая и является нашим
эффектом, в хук useEffect
. Внутри эффекта устанавливаем название документа с помощью
API браузера document.title
. Мы можем прочитать последнее значение счетчика внутри эффекта,
потому что он находится в области видимости нашей функции. Когда React отрисовывает
компонент, он помнит переданный нами эффект, а затем запускает его после обновления DOM.
Это происходит после каждой отрисовки компонента, включая самую первую.
Опытные разработчики JavaScript могут заметить, что функция, переданная хуку useEffect
,
будет отличаться для каждой отрисовки. Так и было задумано. Фактически, это то, что позволяет
нам считывать значение count
внутри эффекта, не беспокоясь о том, что оно устарело. Каждый раз,
когда компонент перерисовывается, мы планируем новый эффект, заменяя предыдущий. Используя
такой подход, можно сказать, что в определённом смысле поведение эффектов - это часть
результата отрисовки: каждый эффект «принадлежит/относится к» определенной отрисовке.
Позднее станет понятней, почему это полезно.
Подсказка!
В отличие отcomponentDidMount
или componentDidUpdate
, эффекты,
запланированные с помощью useEffect
, не блокируют браузер, чтобы обновить экран.
Таким образом приложение становится более отзывчивым. Большинство эффектов не обязаны происходить синхронно.
Но для таких редких случаев (например, нужно получить размеры элемента)
существует отдельный хук useLayoutEffect
с таким же API, как и у хука useEffect
.
Ранее мы рассмотрели, как создавать побочные эффекты, которые не требуют какой-либо очистки. Однако некоторым эффектам она всё же нужна. Допустим, нам нужно настроить подписку на некоторый внешний источник данных. В этом случае важно провести очистку, чтобы избежать утечек памяти! Давайте сравним, как мы можем выполнить очистку в классах и с использованием хуков.
3.12.4.2.1 Пример с использованием класса
В классе React вы обычно устанавливаете подписку в методе ЖЦ componentDidMount
и
очищаете ее в методе componentWillUnmount
. Допустим, у нас есть модуль ChatAPI
,
который позволяет подписаться на онлайн-статус друга. Вот как мы можем подписаться
и отобразить этот статус с помощью класса:
class FriendStatus extends React.Component {
constructor(props) {
super(props);
this.state = { isOnline: null };
this.handleStatusChange = this.handleStatusChange.bind(this);
}
componentDidMount() {
// Код подписки
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentWillUnmount() {
// Код отписки
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
handleStatusChange(status) {
this.setState({
isOnline: status.isOnline
});
}
render() {
if (this.state.isOnline === null) {
return 'Загрузка...';
}
return this.state.isOnline ? 'Онлайн' : 'Офлайн';
}
}
Обратите внимание, что componentDidMount
и componentWillUnmount
должны быть
зеркальны друг другу. Методы ЖЦ заставляют нас размещать коды подписки и отписки по разным местам, хотя
концептуально код в обоих частях этой логики связан с одним и тем же эффектом.
Внимание!
Внимательные читатели могут заметить, что для полной корректности этот пример нуждается также и в методеcomponentDidUpdate
. Пока проигнорируем этот момент,
но вернемся к нему в следующем пункте этого раздела.
3.12.4.2.2 Пример с использованием хука
А теперь посмотрим, как написать этот компонент, используя функционал хуков.
Возможно, вы подумали, что для очистки нам понадобится отдельный эффект. Но коды для
добавления и удаления подписки связаны настолько тесно, что хук useEffect
специально разработан
с учетом того, чтобы поместить их вместе. Если ваш эффект возвращает функцию, React выполнит её,
когда придет время для очистки:
import React, { useState, useEffect } from 'react';
function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null);
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
useEffect(() => {
// Код подписки
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Указываем как производить очистку после этого эффекта:
return function cleanup() {
// Код отписки
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
if (isOnline === null) {
return 'Загрузка...';
}
return isOnline ? 'Онлайн' : 'Офлайн';
}
Почему мы вернули функцию из нашего эффекта? Это опциональный механизм очистки для эффектов. Каждый эффект может возвращать функцию, которая после него выполнит очистку. Это позволяет нам поддерживать коды добавления и удаления подписок максимально близко друг к другу. Они являются частью одного эффекта!
Когда именно React выполняет очистку в эффекте? React производит очистку, когда компонент демонтируется. Однако, как мы уже знаем, эффекты запускаются для каждой отрисовки, а не единожды. Вот почему React также очищает эффекты предыдущей отрисовки, прежде чем запускать эффекты снова. Далее мы обсудим, почему это помогает избежать ошибок и как отказаться от такого поведения в том случае, если это создает проблемы с производительностью.
Внимание!
Вы не обязаны возвращать именованную функцию из эффекта. Функцию выше мы назвалиcleanup
только для того, чтобы прояснить её предназначение. Вы также можете вернуть
стрелочную функцию либо вызвать какой-то другой код.
Мы узнали, что useEffect
позволяет определять различные виды побочных
эффектов, происходящих после отрисовки компонента. Некоторые эффекты
могут требовать очистку, поэтому они должны возвращать функцию:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
Эффекты, не имеющие фазы очистки, ничего не возвращают.
useEffect(() => {
document.title = `Вы нажали ${count} раз`;
});
Хук эффекта объединяет оба случая под одним API.
Если вы чувствуете, что у вас появилось неплохое понимание того, как работает хук эффекта, вы можете перейти к следующему разделу о правилах использования хуков прямо сейчас.
Продолжим раздел, глубже рассматривая некоторые аспекты useEffect
, которые, вероятно,
будут интересны опытным пользователям React. Вам не обязательно копаться в них сейчас.
Вы всегда можете вернуться к этому разделу, чтобы узнать больше о хуке эффекта.
3.12.4.4.1 Совет: используйте несколько эффектов для разделения задач
Одна из проблем, которую мы описали в пункте о мотивации, заключается в том, что методы ЖЦ класса часто содержат несвязанную логику, а связанная логика, наоборот, разбита по разным методам ЖЦ. Вот компонент, который совмещает счетчик и логику индикатора состояния друга из предыдущих примеров:
class FriendStatusWithCounter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0, isOnline: null };
this.handleStatusChange = this.handleStatusChange.bind(this);
}
componentDidMount() {
document.title = `Вы нажали ${this.state.count} раз`;
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentDidUpdate() {
document.title = `Вы нажали ${this.state.count} раз`;
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
handleStatusChange(status) {
this.setState({
isOnline: status.isOnline
});
}
// ...
Обратите внимание, как логика, которая устанавливает document.title
,
продублирована в componentDidMount
и componentDidUpdate
. Логика подписки/отписки
разбита между componentDidMount
и componentWillUnmount
. А componentDidMount
содержит код для обеих задач.
Как же хуки помогают решить эту проблему? По аналогии с многократным использованием хука состояния, можно использовать несколько эффектов. Это позволяет нам разбить несвязанную логику на разные эффекты:
function FriendStatusWithCounter(props) {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Вы нажали ${this.state.count} раз`;
});
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
// ...
}
Хуки позволяют разделить код на основе того, что он делает, а не на основании имён методов ЖЦ. React будет применять каждый эффект, используемый компонентом, в указанном порядке.
3.12.4.4.2 Объяснение: почему эффекты выполняются для каждого обновления?
Если вы привыкли к классам, вам может быть интересно, почему фаза очистки эффекта происходит после каждой повторной отрисовки, а не однократно при демонтировании. Давайте рассмотрим практический пример, чтобы понять, почему именно такой дизайн помогает создавать компоненты с меньшим количеством ошибок.
Ранее в разделе мы представили пример компонента FriendStatus
, который показывает,
находится друг в сети или нет. Наш класс считывает friend.id
из this.props
, подписывается
на статус друга после монтирования компонента и отменяет подписку при демонтировании:
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
Но что произойдет, если свойство friend
изменится, пока компонент отображается
на экране? Наш компонент будет продолжать отображать онлайн-статус, но... другого друга.
Это ошибка. Также мы могли бы вызвать утечку памяти или крэш при демонтировании, так как
вызов отмены подписки будет использовать неверный ID друга.
В компоненте-классе для обработки такого случая нам нужно
добавить componentDidUpdate
:
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentDidUpdate(prevProps) {
// Отписываемся от предыдущего friend.id
ChatAPI.unsubscribeFromFriendStatus(
prevProps.friend.id,
this.handleStatusChange
);
// Подписываемся на следующий friend.id
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
Отсутствие правильной обработки в componentDidUpdate
является
распространенным источником ошибок в приложениях React.
Теперь рассмотрим версию этого компонента, которая использует хуки:
function FriendStatus(props) {
// ...
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
Она не страдает от такой ошибки. (Помимо прочего, мы не внесли никаких изменений.)
Не существует специального кода для обработки обновлений, потому что useEffect
обрабатывает их по умолчанию. Он очищает предыдущие эффекты перед применением
следующих эффектов. Для наглядной иллюстрации вот вам последовательность вызовов
подписки и отмены подписки, которые этот компонент может производить с течением времени:
// Монтирование со свойствами { friend: { id: 100 } }
ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // запускаем первый эффект
// Обновление со свойствами { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // очищаем предыдущий эффект
ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // запускаем следующий эффект
// Обновление со свойствами { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // очищаем предыдущий эффект
ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // запускаем следующий эффект
// Демонтирование
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // очищаем последний эффект
Такое поведение обеспечивает согласованность по умолчанию и предотвращает ошибки, которые часто встречаются в компонентах-классах из-за отсутствия логики обновления.
3.12.4.4.3 Подсказка: как повысить производительность, указывая React пропустить срабатывание эффекта
В некоторых случаях очистка или применение эффекта после каждой отрисовки может
привести к проблемам с производительностью. В компонентах-классах мы можем решить эту проблему,
написав дополнительное сравнение с prevProps
или prevState
внутри componentDidUpdate
:
componentDidUpdate(prevProps, prevState) {
if (prevState.count !== this.state.count) {
document.title = `Вы нажали ${this.state.count} раз`;
}
}
Это довольно частое требование, поэтому оно встроено в API хука useEffect
.
Вы можете указать React пропустить выполнение эффекта, если определенные значения не
изменились между повторными отрисовками. Для этого передайте массив в качестве
необязательного второго аргумента в useEffect
:
useEffect(() => {
document.title = `Вы нажали ${this.state.count} раз`;
}, [count]); // Перевыполнит эффект, только если count изменился
В примере выше мы передаем [count]
в качестве второго аргумента. Что это значит?
Если count равен 5
, а затем наш компонент повторно отрисовывается с count
, все еще
равным 5
, React будет сравнивать [5]
из предыдущей отрисовки и [5]
из следующей.
Поскольку все элементы в массиве одинаковы (5 === 5)
, React не вызовет эффект.
Это и есть наша оптимизация.
Когда мы отрисовываем компонент с count
, обновленным до 6
, React будет
сравнивать элементы в массиве [5]
из предыдущей отрисовки с элементами в
массиве [6]
из следующей. На этот раз React повторно выполнит эффект, потому
что 5 !== 6
. Если в массиве несколько элементов, React повторно запустит
эффект, если отличается хотябы один из них.
Это также справедливо для эффектов, которые имеют фазу очистки:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // Повторно выполнит подписку, если props.friend.id изменился
В будущем второй аргумент может быть добавлен автоматически при преобразовании во время сборки.
Внимание!
Если вы используете эту оптимизацию, убедитесь, что массив содержит все значения из окружающей области видимости, которые изменяются со временем и используются эффектом. В противном случае ваш код будет ссылаться на устаревшие значения из предыдущих отрисовок. Также мы обсудим и другие варианты оптимизации в справке по API хуков.Если вы хотите запустить эффект и очистить его только один раз (при монтировании и демонтировании), вы можете передать пустой массив
[]
в качестве второго аргумента. Это укажет
React, что ваш эффект не зависит от каких-либо значений из props
или state
, поэтому его не нужно
повторно выполнять. Это не обрабатывается как особый случай, а следует непосредственно из того,
как работает массив входных значений. Несмотря на то, что передача []
ближе к знакомой ментальной
модели componentDidMount
и componentWillUnmount
, мы рекомендуем не привыкать к такой форме записи, поскольку
это часто приводит к ошибкам, что обсуждалось выше. Не забывайте, что React откладывает запуск
useEffect
до тех пор, пока браузер не выполнит прорисовку, поэтому выполнение дополнительной
работы - это не проблема.
Поздравляем! Это был довольно большой раздел, и мы надеемся, что к его концу нам удалось ответить на большинство ваших вопросов об эффектах. Вы изучили хук состояния и хук эффекта. Вместе они позволяют решить широкий спектр задач, охватывая большую часть случаев, где ранее использовались классы. В оставшихся случаях, могут оказаться полезными дополнительные хуки.
Также становится понятно, как хуки решают проблемы, описанные в пункте о мотивации.
Мы видели, как очистка эффекта предотвращает дублирование кода в componentDidUpdate
и componentWillUnmount
,
распологает связанный код вместе и помогает избежать ошибок. Мы также видели, как можно
разделить эффекты по назначению, что вообще невозможно для классов.
В этот момент вы всё же можете задаться вопросом, как работают хуки.
Как React знает, какой вызов useState
какой переменной состояния соответствует
между повторными отрисовками? Как React «сопоставляет» предыдущие и последующие
эффекты при каждом обновлении? В следующем разделе мы узнаем о
правилах использования хуков - эти правила необходимы для их работы.