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

Возвращаемая функция для сброса в useEffect срабатывает чаще чем вы думаете

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

Пример на codesandbox

Почему это так: разработчики реакт решили реализовать в useEffect весь финкционал методов жизненного цикла (почти). Представьте себе ситуацию, когда вам нужно установить какой-нибудь timeout, который должен как-то работать с состоянием компонента. Как вы бы сделали это в классовом компоненте: 

  • в componentDidMount установили бы таймер

componentDidMount() {
    const { stateForTimer } = this.state;
    this.timer = setTimeout(() => console.log(stateForTimer), 1000);
}
  • в componentDidUpdate сбрасывали бы этот таймер и устанавливали заново, если мы это не сделаем, в таймере будет неактуальное состояние.

componentDidUpdate() {
    const { stateForTimer } = this.state;
    clearTimeout(this.timer);
    this.timer = setTimeout(() => console.log(stateForTimer), 1000);
}
  • в componentWillUnmount мы отписываемся от таймера, тк не хотим, чтобы он сработал, если у нас уже нет компонента на экране.

componentWillUnmount() {
    clearTimeout(this.timer);
}
  • Но таким образом наш таймер будет отрабатывать при каждом ререндере, в родительском компоненте, чтобы этого не происходило мы можем вместо Component использовать PureComponent

export default class ClassComponentTimer extends React.PureComponent {
  constructor() {
    super();
    this.state = { stateForTimer: "state For Timer" };
    this.timer = null;
  }
  componentDidMount() {
    const { stateForTimer } = this.state;
    this.timer = setTimeout(() => console.log(stateForTimer), 1000);
  }

  componentDidUpdate() {
    const { stateForTimer } = this.state;
    clearTimeout(this.timer);
    this.timer = setTimeout(() => console.log(stateForTimer), 1000);
  }

  componentWillUnmount() {
    clearTimeout(this.timer);
  }

  inputHandler = (e) => {
    this.setState({ stateForTimer: e.target.value });
  };

  render() {
    return (
      <div className="class">
        <h1>Class timer</h1>
        <input value={this.state.stateForTimer} onChange={this.inputHandler} />
      </div>
    );
  }
}

Согласитесь, получилось довольно многословно.

Вы могли заметить, что мы отписываемся от таймера 2 раза и подписываемся тоже 2 раза. так вот чтобы не повторяться, разработчики реакт в хуках объединили этот механизм в одном месте и теперь наш таймер сбросится, когда происходит ререндер или, если есть список зависимостей, когда меняется зависимость, чтобы избежать работы с неактуальными данными, и когда произойдет unmount компонента, чтобы не произошли сайдэфекты, которые нам уже по сути не нужны.

export default function App() {
  const [stateForTimer, setStateForTimer] = useState("state For Timer");

  useEffect(() => {
    const timeout = setTimeout(() => console.log(stateForTimer), 1000);
    return () => clearTimeout(timeout);
  }, [stateForTimer]);

  const inputHandler = (e) => {
    setStateForTimer(e.target.value);
  };

  return (
    <div className="func">
      <h1>Function timer</h1>
      <input value={stateForTimer} onChange={inputHandler} />
    </div>
  );
}

В плане лаконичности компоненты на хуках конечно выигрывают.

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

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

пример на codesandbox

Это значит, что какие-то сложные вычисления не стоит делать внутри компонента напрямую, а сайдэфекты вообще нельзя, потому что они будут срабатывать бесконтрольно, лучше сделать это в useEffect или в useMemo.

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

Вызывать функции в useState будет отрабатывать на каждый ререндер

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

пример на codesandbox

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

import { useState } from "react";

const twoSquared = () => {
  console.log("do some maths once");
  return 3 * 3;
};

export default function StateOnce() {
  const [nine] = useState(() => twoSquared());

  return (
    <div className="every render">
      <p> 3 * 3 = {nine} </p>
    </div>
  );
}

setState в функциональных компонентах может принимать функцию

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

Выведится 5 раз 5
for (let i = 1; i <= 5; i++) {
  setCount(count + 1);
}

Выведится от 1 до 5
for (let i = 1; i <= 5; i++) {
	setCount((prevCount) => prevCount + 1);
}

Пример немного надуманный, но может быть ситуация, когда вам нужно пробежаться по какому-нибудь массиву и на каждый элемент этого массива вызвать setState - это уже что-то более реалистичное.

Пример кода на codesandbox

Помимо useEffect есть еще спецефичный хук useLayoutEffect

useLayoutEffect так же как и useEffect сработает после рендера, но в отличии от useEffect срабатывает он синхронно.

Это значит, что при использовании useEffect у браузера будет какое-то время для отрисовки контента, а значит, что если мы пытаемся что-то отрендерить, а в useEffect мы например следим за тем, что пытаемся отрендерить и хотим перехватить это и отрисовать вместо этого какое-то другое значение, то мы увидем небольшое моргание. Но лучше один раз увидеть пример, чем сто раз прочитать его описание.

Пример кода на codesandbox

в примере из codesandbox потыкайте много раз подряд на кнопку «Get name with delay», вы увидите небольшое мерцание.

Тут происходит следующее:

  1. По нажатию на кнопку мы устанавливаем Имя в состояние компонента, оно записывается в виртуальный DOM

  2. Запускается useEffect

  3. Код внутри него откладывается в асинхронную очередь

  4. js завершает все операции что лежат в стэке вызовов (привет Event loop), в том числе отображение пользователю того, что мы записали в состояние компонента по нажатию на кнопку.

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

  6. Мы его выполняем, меняется виртуальное дерево

  7. Рендерится уже то, что мы задали в состояние внутри этого колбэка.

useLayoutEffect же вместо того, чтобы отправлять колбэк в асинхронную очередь, сразу же выполняет код, ну точнее не совсем сразу, сначала отрабатывает setState из кнопки, идет изменение в виртуальном дом дереве, после этого отрабатывает useLayoutEffect, но он в отличии от useEffect не откладывает колбэк в асинхронную очередь вызовов, а выполняет его сразу, еще раз меняется виртуальное ДОМ дерево и после этого уже происходит ререндер реального браузерного дерева. Попробуйте быстро потыкать кнопку «Get name without delay», моргания вы не увидите.

Аккуратно с 0 при проверке на необходимость рендеринга

Напоследок минипредостережение. В обычном JS коде мы часто делаем такую проверку: 

if(arr.length) {
  //если длинна массива !== 0, работаем с элементами массива
  arr.map((item) => {
 		//ваш код
  })
}

И кажется логичным сделать такую же проверку в рендере компонента:

items.length && items.map((item) => <div>{item}</div>)

Но реакт в таком случае отработает не так как мы ожидаем. Если длинна массива больше нуля, то все ок, мы пробежимся по массиву и отрендерим что нужно, но если она равна 0, то он вместо того, чтобы отрендерить нам «ничего», отобразит 0.

Для такого случая можно использовать такую проверку:

import "./styles.css";

const items = [];

export default function App() {
  return (
    <div className="App">
      <h2>Check array on arr.length:</h2>
      {items.length && items.map((item) => <div>{item}</div>)}
      <h2>Check array on arr.length {">"} 0:</h2>
      {items.length > 0 && items.map((item) => <div>{item}</div>)}
    </div>
  );
}

Надеюсь эта статья была кому-нибудь полезна. Если вы увидели какие-то ошибки, буду рад, если вы напишете о них, я обязательно внесу исправления.

Комментарии (9)


  1. 4reddy
    28.07.2022 01:17
    +3

    По пункту Возвращаемая функция для сброса в useEffect срабатывает чаще чем вы думаете добавлю слова Дэна:

    not every side effect needs to be expressed as an Effect. Most side effects belong in event handlers. But side effects caused by rendering should be written as Effects.

    + его тред годичной давности
    https://twitter.com/dan_abramov/status/1281669881667162112

    + статью в новой доке React
    https://beta.reactjs.org/learn/you-might-not-need-an-effect


  1. borshak
    28.07.2022 10:02
    +2

    items.length > 0 == !!items.length, а выглядит более компактно и опрятно.


    1. Mi7teR
      29.07.2022 11:23
      +2

      и не особо понятно с первого взгляда, это да


      1. borshak
        30.07.2022 22:42

        Вот тут бы поспорил. При достачном опыте на JS все понятно, поскольку такая конструкция идиоматична для JS (приведение длины массива к типу boolean, что по сути можно прочитать как "есть ли в массиве элементы?"; должен заметить, что тут операция приведения типа использована по прямому назначению).

        Контрпример - использование побитовой инверсии для сравнения результата выражения с нулем, с последующим неявным приведением к boolean, то есть:

        if (~"some string".indexOf("ome")) { //... string contains substring
        вместо
        if ("some string".indexOf("ome") !== -1) { //... string contains substring

        Вот здесь - да, согласен, ничего не понятно.


  1. kahi4
    28.07.2022 11:27
    +1

    Для такого случая можно использовать такую проверку:

    А можно вообще проверку не делать, зачем она тут? Map по пустому массиву вернет пустой массив, который реакт проигнорирует.

    А если нужно проверить массив ли это вообще, лучше так и проверять: Array.isArray.

    Остальное — переписка из документации и абсолютно все уроки делают акцент на том как работает useEffect потому что это его основная фишка.

    чтобы этого не происходило мы можем вместо Component использовать PureComponent

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

    сайдэфекты лучше отдать на откуп useEffect или useMemo

    Кто делает сайдэффнкы в useMemo? Он не для этого нужен. Ну а еще в документации написано, что useMemo не гарантирует жизненный цикл и может быть сброшен когда реакту вздумается (т.е. сайд эффекты делать в нем нельзя)

    А все дело в том, что setState - асинхронная операция и если вы например в каком-нибудь цикле попытаетесь поработать с текущим значением состояния

    В вашем примере оно вело бы себя так же даже если setState был синхронный. Все дело в замыкании, а не асинхронности (конкретно в примере и в functional components, в классах проблема асинхронности присутствует. К слову, лайфхак, в классе можно сделать await setState(…), но это просто случайность, а не задуманное поведение, не делайте так)


    1. dunai12 Автор
      28.07.2022 12:54

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


      1. kahi4
        28.07.2022 14:40
        +1

        Я специально полез проверить кодсэндбркс что мы говорим о функциональном компоненте. У вас имеется

        const onClickWithState = () => {
        for (let i = 1; i <= 5; i++) {
        setCount(count + 1);
        }
        };

        Вы замыкаетесь на count в момент объявления функции onClickWithState, которая (переменная count) еще и объявлена как const. При следующем рендеринге переменная count будет физически совершенно новой переменной. Более того, функция (useState, в данном случае) не смогла бы обновить переменную даже если бы очень хотела даже если бы была пять раз синхронной (опустим изотерические манипуляции с js наподобие переопределения toPrimitive).

        Так что в данном случае проблема в замыкании. Я на этом акцентирую внимание, потому что пять раз подряд обновить state нетнастолько частый случай, как обратиться к устаревшему стейту через это самое замыкание в асинхронной функции:

        const onClick = async() {
           const newValue = await someReallyLongOperation<>
           setState(prevData + newValue); // замкнулись на prevData тут, она могла устареть, пожтому нужно использоватт callback
        } 


        1. dunai12 Автор
          28.07.2022 15:40

          Блин, кажется я понял о чем ты, ты прав, спасибо!


        1. knoppixjs
          29.07.2022 11:16

          Дело тут не в замыкании, а в батчинге setState, если бы не было батчинга то 5 setState = 5 рендеров и проблем бы в этом примере не было