Перейти к содержанию

Использование хука эффекта

Хуки — нововведение в React 16.8, которое позволяет использовать состояние и другие возможности React без написания классов.

Хук эффекта даёт вам возможность выполнять побочные эффекты в функциональном компоненте:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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>
  )
}

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

Побочными эффектами в React-компонентах могут быть: загрузка данных, оформление подписки и изменение DOM вручную. Неважно, называете ли вы эти операции «побочными эффектам» (или просто «эффектами») или нет, скорее всего вам доводилось ранее использовать их в своих компонентах.

Совет

Если вам знакомы классовые методы жизненного цикла React, хук useEffect представляет собой совокупность методов componentDidMount, componentDidUpdate, и componentWillUnmount.

Существует два распространённых вида побочных эффектов в компонентах React: компоненты, которые требуют и не требуют сброса. Давайте рассмотрим оба примера более детально.

Эффекты без сброса

Иногда мы хотим выполнить дополнительный код после того, как React обновил DOM. Сетевые запросы, изменения DOM вручную, логирование — всё это примеры эффектов, которые не требуют сброса. После того, как мы запустили их, можно сразу забыть о них, ведь больше никаких дополнительных действий не требуется. Давайте сравним, как классы и хуки позволяют нам реализовывать побочные эффекты.

Пример с использованием классов

В классовых React-компонентах метод render сам по себе не должен вызывать никаких побочных эффектов. Он не подходит для этих целей, так как в основном мы хотим выполнить наши эффекты после того, как React обновил DOM.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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>
    )
  }
}

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

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

А сейчас, давайте рассмотрим, как мы можем сделать то же самое с использованием хука useEffect.

Пример с использованием хуков

Мы уже рассматривали этот пример немного ранее, но давайте разберём его более подробно:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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)}>
        Нажми на меня
      </button>
    </div>
  )
}

Что же делает useEffect? Используя этот хук, вы говорите React сделать что-то после рендера. React запомнит функцию (то есть «эффект»), которую вы передали и вызовет её после того, как внесёт все изменения в DOM. В этом эффекте мы устанавливаем заголовок документа, но мы также можем выполнить запрос данных или вызвать какой-нибудь императивный API.

Почему же мы вызываем useEffect непосредственно внутри компонента? Это даёт нам доступ к переменной состояния count (или любым другим пропсам) прямиком из эффекта. Нам не нужен специальный API для доступа к этой переменной — она уже находится у нас в области видимости функции. Хуки используют JavaScript-замыкания, и таким образом, им не нужен специальный для React API, поскольку сам JavaScript уже имеет готовое решение для этой задачи.

Выполняется ли useEffect после каждого рендера? Разумеется! По умолчанию он будет выполняться после каждого рендера и обновления. Мы рассмотрим, как настраивать это немного позже. Вместо того, чтобы воспринимать это с позиции «монтирования» и «обновления», мы советуем просто иметь в виду, что эффекты выполняются после каждого рендера. React гарантирует, что он запустит эффект только после того, как DOM уже обновился.

Подробное объяснение

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

1
2
3
4
5
6
function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `Вы нажали ${count} раз`;
  });

Мы объявляем переменную состояния count и говорим React, что мы хотим использовать эффект. Далее, мы передаём функцию в хук useEffect. Эта функция как раз и будет нашим эффектом. Внутри этого эффекта мы устанавливаем заголовок документа, используя API браузера document.title. Мы можем получать доступ к актуальной переменной count изнутри эффекта, так как он находится в области видимости нашей функции. Когда React рендерит наш компонент, он запоминает эффект, который мы использовали, и запускает его после того, как обновит DOM. Это будет происходить при каждом рендере, в том числе и при первоначальном.

Опытные JavaScript-разработчики могут подметить, что функция, которую мы передаём в useEffect, будет меняться при каждом рендере. На самом деле, это было сделано преднамеренно. Это как раз то, что даёт нам возможность получать актуальную версию переменной count изнутри эффекта, не беспокоясь о том, что её значение устареет. Каждый раз при повторном рендере, мы ставим в очередь новый эффект, который заменяет предыдущий. В каком-то смысле, это включает поведение эффектов как часть результата рендера, то есть каждый эффект «принадлежит» определённому рендеру. Мы расскажем о преимуществах данного подхода далее на этой странице.

Совет

В отличие от componentDidMount или componentDidUpdate, эффекты, запланированные с помощью useEffect, не блокируют браузер при попытке обновить экран. Ваше приложение будет быстрее реагировать на действия пользователя, даже когда эффект ещё не закончился. Большинству эффектов не нужно работать в синхронном режиме. Есть редкие случаи, когда им всё же нужно это делать (например, измерять раскладку), но для этого мы разработали специальный хук useLayoutEffect с точно таким же API, как и у useEffect.

Эффекты со сбросом

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

Пример с использованием классов

В React-классе, вы, как правило, оформили бы подписку в componentDidMount и отменили бы её в componentWillUnmount. Например, предположим, что у нас есть некий модуль ChatAPI, с помощью которого мы можем подписаться на статус друга в сети. Вот как мы бы подписались и отобразили бы статус, используя класс:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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. Мы вернёмся к этому моменту ниже на этой странице.

Пример с использованием хуков

Давайте рассмотрим, как этот компонент будет выглядеть, если написать его с помощью хуков.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React, { useState, useEffect } from 'react'

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null)

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline)
    }

    ChatAPI.subscribeToFriendStatus(
      props.friend.id,
      handleStatusChange
    )
    // Указываем, как сбросить этот эффект:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(
        props.friend.id,
        handleStatusChange
      )
    }
  })

  if (isOnline === null) {
    return 'Загрузка...'
  }
  return isOnline ? 'В сети' : 'Не в сети'
}

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

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

Совет

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

Итог

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }

  ChatAPI.subscribeToFriendStatus(
    props.friend.id,
    handleStatusChange
  )
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(
      props.friend.id,
      handleStatusChange
    )
  }
})

В некоторых эффектах нет этапа сброса, поэтому они не возвращают ничего.

1
2
3
useEffect(() => {
  document.title = `Вы нажали ${count} раз`
})

Хук эффекта покрывает оба сценария единым API.


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


Советы по использованию эффектов

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

Совет: используйте разные хуки для разных задач

Один из ключевых моментов, которые мы описали в мотивации, приводит аргументы о том, что в отличии от хуков, классовые методы жизненного цикла часто содержат логику, которая никак между собой не связана, в то время как связанная логика, разбивается на несколько методов. Далее мы приведём пример компонента, который объединяет в себе логику счётчика и индикатора статуса нашего друга из предыдущих примеров:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 включает в себя логику для обеих задач.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0)
  useEffect(() => {
    document.title = `Вы нажали ${count} раз`
  })

  const [isOnline, setIsOnline] = useState(null)
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline)
    }

    ChatAPI.subscribeToFriendStatus(
      props.friend.id,
      handleStatusChange
    )
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(
        props.friend.id,
        handleStatusChange
      )
    }
  })
  // ...
}

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

Объяснение: почему эффекты выполняются при каждом обновлении

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

Ранее на этой странице, мы рассматривали пример с компонентом FriendStatus, который отображает в сети наш друг или нет. Наш класс берёт friend.id из this.props, подписывается на статус друга после того, как компонент смонтировался, и отписывается во время размонтирования.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

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

В классовом компоненте нам бы пришлось добавить componentDidUpdate, чтобы решить эту задачу:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  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.

Теперь давайте рассмотрим версию этого же компонента, но уже написанного с использованием хуков:

1
2
3
4
5
6
7
8
9
function FriendStatus(props) {
  // ...
  useEffect(() => {
    // ...
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

Этого бага в данном компоненте нет. (Но мы и не изменили там ничего)

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Монтируем с пропсами { friend: { id: 100 } }
ChatAPI.subscribeToFriendStatus(100, handleStatusChange) // Выполняем первый эффект

// Обновляем с пропсами { friend: { id: 200 } }
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange) // Сбрасываем предыдущий эффект
ChatAPI.subscribeToFriendStatus(200, handleStatusChange) // Выполняем следующий эффект

// Обновляем с пропсами { friend: { id: 300 } }
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange) // Сбрасываем предыдущий эффект
ChatAPI.subscribeToFriendStatus(300, handleStatusChange) // Выполняем следующий эффект

// Размонтируем
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange) // Сбрасываем последний эффект

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

Совет: оптимизация производительности за счёт пропуска эффектов

В некоторых случаях сброс или выполнение эффекта при каждом рендере может вызвать проблему с производительностью. В классовых компонентах, мы можем решить это используя дополнительное сравнение prevProps или prevState внутри componentDidUpdate:

1
2
3
4
5
componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `Вы нажали ${this.state.count} раз`;
  }
}

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

1
2
3
useEffect(() => {
  document.title = `Вы нажали ${count} раз`
}, [count]) // Перезапускать эффект только если count поменялся

В этом примере, мы передаём [count] вторым аргументом. Что это вообще значит? Это значит, что если count будет равен 5 и наш компонент повторно рендерится с тем же значением count = 5, React сравнит [5] из предыдущего рендера и [5] из следующего рендера. Так как, все элементы массива остались без изменений (5 === 5), React пропустит этот эффект. Это и есть оптимизация данного процесса.

Когда при следующем рендере наша переменная count обновится до 6, React сравнит элементы в массиве [5] из предыдущего рендера и элементы массива [6] из следующего рендера. На этот раз, React выполнит наш эффект, так как 5 !== 6. Если у вас будет несколько элементов в массиве, React будет выполнять наш эффект, в том случае, когда хотя бы один из них будет отличаться.

Это также работает для эффектов с этапом сброса:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline)
  }

  ChatAPI.subscribeToFriendStatus(
    props.friend.id,
    handleStatusChange
  )
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(
      props.friend.id,
      handleStatusChange
    )
  }
}, [props.friend.id]) // Повторно подписаться, только если props.friend.id изменился

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

Примечание

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

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

Если вы передадите пустой массив ([]), пропсы и состояние внутри эффекта всегда будут иметь значения, присвоенные им изначально. Хотя передача [] ближе по модели мышления к знакомым componentDidMount и componentWillUnmount, обычно есть более хорошие способы избежать частых повторных рендеров. Не забывайте, что React откладывает выполнение useEffect, пока браузер не отрисует все изменения, поэтому выполнение дополнительной работы не является существенной проблемой.

Мы рекомендуем использовать правило exhaustive-deps, входящее в наш пакет правил линтера eslint-plugin-react-hooks. Оно предупреждает, когда зависимости указаны неправильно и предлагает исправление.

Следующие шаги

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

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

На этом этапе, вы, возможно, задаётесь вопросом, как хуки работают в целом. Как React понимает, какая переменная состояния соответствует какому вызову useState между повторными рендерами? Как React «сопоставляет» предыдущие и следующие эффекты при каждом обновлении? На следующей странице, мы узнаем о правилах хуков, так как они являются залогом правильной работы хуков.

Комментарии