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 имеют два основных вида побочных эффектов: требующие очистки, и не требующие. Давайте разберём это различие более подробно.



3.12.4.1 Эффекты, не требующие очистки


Иногда мы хотим выполнить дополнительный код после того, как 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 Эффекты с очисткой


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


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 только для того, чтобы прояснить её предназначение. Вы также можете вернуть стрелочную функцию либо вызвать какой-то другой код.



3.12.4.3 Резюме


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


Код
    
  useEffect(() => {
      ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
      return () => {
        ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
      };
  });
  

Эффекты, не имеющие фазы очистки, ничего не возвращают.


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

Хук эффекта объединяет оба случая под одним API.

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



3.12.4.4 Подсказки по использованию эффектов


Продолжим раздел, глубже рассматривая некоторые аспекты 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 до тех пор, пока браузер не выполнит прорисовку, поэтому выполнение дополнительной работы - это не проблема.



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


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

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

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