Подписывайтесь на мой твиттер, там всегда что-нибудь интересное!

useEffect(fn, []) это не новый componentDidMount()

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

Перевод статьи useEffect(fn, []) is not the new componentDidMount()

Мы часто делаем какие-нибудь настройки в компоненте при первом монтировании, например запросы в сеть или начинаем слушать события. Так мы приучили себя думать в границах “моментов времени”, таких как componentDidMount()componentDidUpdate() и componentWillUnmount(). Вполне естественно брать предыдущие знания за основу и искать их аналоги при реализации на хуках. Я сам так делал и да, думаю, что каждый так делал. Часто я слышу на своих воркшопах такую фразу:

‘Какой эквиалент в хуках у [любой метод жизненного цикла]’

Быстрый ответ тут таков, что хуки это сдвиг парадигмы от того, чтобы думать в “жизненных циклах и времени” к ведению рассуждений в “основываясь на стейте и синхронизации в DOM”. Если вы возьмете старую парадигму и примените её к хукам, то она просто не будет работать как надо и вполне может застопорить всю вашу работу.

Но эта тема полна свойственной React терминологии и без глубокого и осязаемого объяснения она просто забудется.

Когда разработчики только начинают изучать хуки, до этого имея опыт работы только на классах, то они склонны думать, что мол “ага мне нужно запустить какой-либо код единожды при монтировании как с componentDidMount(). Ааа, я вижу, что useEffect имеет пустой массив как зависимость. Окей, я знаю как это работает.”

Такой ход мыслей приведет нас к нескольким проблемам:

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

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

Поймите, что рефакторинг из классов на хуки не будет означать банальной замены componentDidMount на useEffect(fn, []).

Они запускаются в разные временные отрезки

Сначала давайте поговорим о моменте времени выполнения каждого из них. componentDidMount запускается после монтирования компонента. По документации, если вы сразу же выставляете стэйт(синхронно), то React уже знает как инициировать дополнительный рендер и использовать второй респонс как изначальный UI, чтобы пользователь не увидел мигание между ререндерингом компонентов. Представьте, что вам надо узнать ширину DOM элемента с componentDidMount и вам надо обновить стэйт, чтобы что-нибудь отработало в зависимости от ширины взятого элемента. Вот последовательность действий:

Компонент рендерится впервые

Отдаваемое значение render() используется для монтирования нового DOM.

componentDidMount срабатывает и сразу же выставляет стэйт.

Изменение стэйта означает то, что render() вызывается снова и отдаёт новый JSX, который заменяет предыдущий рендер.

Браузер показывает только второй рендер, чтобы избежать мигания промежуточных рендеров.

Вот пример

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

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

Как же тогда получить старое поведение когда нам это нужно?

useLayoutEffect был разработан для того, чтобы иметь такой же тайминг как и у componentDidMount. В общем useLayoutEffect(fn, []) куда ближе по поведению к componentDidMount(), чем useEffect(fn, []). Ну по крайней мере с точки зрения тайминга.

Означает ли это, что нам надо использовать useLayoutEffect?

Возможно нет.

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

“Подхватывание” пропсов и стэйтов

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

class App extends React.Component {
  state = {
    count: 0
  };

  componentDidMount() {
    longResolve().then(() => {
      alert(this.state.count);
    });
  }

  render() {
    return (
      <div>
        <button
          onClick={() => {
            this.setState(state => ({ count: state.count + 1 }));
          }}
        >
          Count: {this.state.count}
        </button>
      </div>
    );
  }
}

При загрузке страницы у нас есть три секунды, чтобы кликнуть на кнопку несколько раз, перед тем как longResolve завершится. Далее выскочит алерт, говорящий актуальное значение count. С этим класс-компонентом, кликнув пять раз вы получите 5 в алерте.

Теперь давайте отрефакторим это в хуках.

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    longResolve().then(() => {
      alert(count);
    });
  }, []);

  return (
    <div>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        Count: {count}
      </button>
    </div>
  );
}

Поиграться

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

Разница тут в том, что useEffect хватает значение каунтера когда он создаётся. Когда мы отдаем коллбэк к useEffect, он задерживается в памяти, где он только знает то, что count равен 0 когда был создан. С кодом на классах, componentDidMount не имеет замыканий в стэйте, так что он просто читает актуальное значение.

Чтобы помочь это понять, представьте, что JS хранит коллбэк в памяти примерно так:

// Это память, мне тут надо попридержать функцию. А и когда она создастся, там будут значения, такие как `count: 0`. Их надо тоже запомнить
() => {
// Хоть это и не так работает, но с точки зрения ментальной модели это как бы как будто count это просто переменная с указанным значением 0.
const count = 0
  longResolve().then(() => {
    alert(count);
  });
}

Вот ещё один пример того, как useEffect захватывает значения.

В статье Дэна Абрамова “A Complete Guide to useEffect”, примеры схожи и показывают поведение setInterval которое вы бы могли ожидать в классах, но не в хуках:

// Версия на классах:
class App extends React.Component {
  state = { count: 0 }

  componentDidMount() {
    setInterval(() => {
      this.setState({ count: this.state.count + 1 })
    }, 1000);
  }

  render() {
    return <div>{this.state.count}</div>
  }
}

// То, что нам кажется эквивалентном на хуках:
function App() { 
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)
    }, 1000);
    return () => clearInterval(id)
  }, [])

  return <div>{count}</div>
}

В примере с классом наш каунтер увеличивается каждую секунду. А в примере с хуками он увеличивается только с 0 до 1 и затем останавливается. Но тут интересно то, что сам интервал на самом деле не останавливается. Причиной такого поведения является то, что коллбэк useEffect как бы “захватывает” то, что он видит как count при создании. Этот колбэк всегда думает, что count равен 0 и следовательно мы всегда будем к нулю прибавлять единицу.

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

Помните, что это не урок того, как работает setInterval с хуками, это скорее о том как применить ментальную модель с классов на хуки и о том как useEffect “захватывает” значения.

Для начала нам надо понять почему массив с зависимостями так назван.

Если ваш эффект “зависит” от чего то, то оно должно быть указано в вышеупомянутом массиве.

Наш код зависит от переменной c каунтером в стэйте. Вообще, нам надо было так сделать уже давным давно.

useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)
    }, 1000)
    return () => clearInterval(id)
  }, [count])

Теперь код работает так, как нам надо, потому что мы хотим, чтобы коллбэк в useEffect запускался только тогда, когда каунт меняется. Когда это происходит, то предыдущий коллбэк в памяти очистится и следовательно setInterval будет удален. Затем React создаст совершенно новый callback в памяти, который уже будет знать, что count равен 1.

// Эй память, нам тут надо функцию сохранить...
() => {
  const count = 0
  const id = setInterval(() => {
    setCount(count + 1)
  }, 1000)
  return () => clearInterval(id)}

// Потом при смене каунта...
// Эй память, очисти первую функцию  
// и потом нам надо захостить вторую...
() => {
  const count = 1
  const id = setInterval(() => {
    setCount(count + 1)
  }, 1000)
  return () => clearInterval(id)
}

Если мы хотим узнать актуальное значение, а не которое “захватили”, то вы всегда можете использовать рефы.

Я думаю, что пример с setInterval это отличный способ понять суть “захватывания” в useEffect, хотя стоит отметить, что есть ещё одно API для указания стэйта, в котором мы передаём функцию, а не значение. React вызовет функцию с существующим стэйтом:

useEffect(() => {
  const id = setInterval(() => {
    // Когда мы передаем функцию, React вызывает её с
    // актуальным стейтом и всякий раз в этом случае она же и становится актуальным стэйтом.
    setCount(count => count + 1)
  }, 1000)
  return () => clearInterval(id)
}, [])

Теперь наш эффект не “стесняет” переменную count и теперь нам не нужно добавлять массив с зависимостями, и мы не стесняем его, следовательно ничего не указывается как исходное значение при запуске эффекта. Хоть это уже немного и отклонение от основной темы, но сейчас мы хотя бы понимаем суть “захватывания” для эффектов и то, что есть другой способ выставить стэйт — будем надеятся, что это win-win при обучении хукам.

“Захват” это хорошо или плохо?

Есть некоторые баги, которых можно избежать во время использования “захвата”, а не актуального значения. Посмотрите этот пример от Дэна Абрамова https://codesandbox.io/s/pjqnl16lm7, где он показывает как “захват” отдаёт вам ожидаемое поведение с поведением компонента класса, который отдаёт актуальный стэйт. В этом примере мы можем подписаться на кого-нибудь и затем быстро менять профайлы. Когда мы меняем профайлы перед получением ответа из сети, существует баг, когда показывается имя нашего последнего подписчика. Это баг с версией на классах, а не с версией на хуках. Почитайте тут про это.

Что там с рефакторингом с классов на хуки?

Предположим, что вы написали такой код:

class UserProfile extends React.Component {
  state = { user: null }

  componentDidMount() {
    getUser(this.props.uid).then(user => {
      this.setState({ user })
    })
  }

  render() {
    // ...
  }
}

Видите баг?

Что случиться если проп uid изменится? Мы не увидим нового пользователя, потому что мы не обрабатываем это изменение в componenDidUpdate. Обычно, если componentDidMount делает сайд-эффекты, зависящие от пропсов или стэйта, то вам в помощь тогда придет componentDidUpdate, чтобы обработать сайд-эффект уже тогда, когда проп или стэйт изменится. Но иногда получается не совсем так идеально и мы можем напороться на баг, забыв о вышеописанном.

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

function UserProfile({ uid }) {
  const [user, setUser] = useState(null)

  useEffect(() => {
    getUser(uid).then(user => {
      setUser(user)
    })
  }, []) // buggy without `uid` in this array
  // ...
}

Этот код будет работать как надо до тех пор, пока у UserProfile не поменяется пропс uid. Если у вас уже на борту есть дополнительные правила по листингу хуков, то вы будете получать предупреждения пока не добавите [uid], как массив зависимостей. Учитывая это, версия с хуками будет делать тоже самое, что делали бы componentDidMount и componentDidUpdate. В общем вы видите, что сам вопрос “Является ли useEffect с пустым массивом зависимостей новой версией componentDidMount?” Сам по себе некорректен и тут стоит сразу начинать с того, что componentDidMount зачастую просто не может быть отрефакторен в useEffect(fn, []).

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

useEffect(() => {
  let isCurrent = true
  getUser(uid).then(user => {
    if (isCurrent) {
      setUser(user)
    }
  })
  return () => {
    isCurrent = false
  }
}, [uid])

Заключение

Думать во временных границах это то, как мы делали с компонентами класса. Теперь нам надо думать “как выглядит UI с этим стейтом?” и “когда стэйт изменяется, какие сайд-эффекты надо перезапускать.” Попробуйте отталкиваться от стэйта, а не от “тайминга жизненного цикла” компонента.