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

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

  1. Мы начнем с примера использования useEffect, в котором есть ошибка.
  2. Затем мы попытаемся демистифицировать причину этой ошибки 😀.
  3. И, наконец, мы увидим, как избежать этих ошибок и написать эффекты, о которых легко рассуждать.

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

В стране крючков функции королей.

Хватит предыстории. Приступим сейчас.

Резюме

Побочные эффекты - неотъемлемая часть любых веб-приложений. Получение данных, изменение DOM вручную и настройка подписки - все это примеры побочных эффектов. Хук useEffect позволяет выполнять побочные эффекты в функциональном компоненте.

// Inside your function component 
 useEffect(() => {
 // some side effect code 
 });
}

Я видел, как некоторые разработчики предполагали, что это всегда один и тот же эффект (анонимная функция), который React вызывает после каждого рендеринга. Но это не так.
Каждый раз, когда происходит повторная визуализация, мы планируем использовать новый эффект, заменяющий предыдущий эффект. Это сделано намеренно и важно, так как это делает эффект больше похожим на часть результата рендеринга. Здесь важно помнить, что каждый эффект «принадлежит» определенному рендеру.

Каждый эффект «принадлежит» определенному рендеру.

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

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

React всегда очищает предыдущий эффект перед применением следующего.

Оставив в стороне основы, давайте перейдем к интересной части.

1. Эффект глючности

Вот пример фрагмента кода, демонстрирующий использование setInterval (побочный эффект) внутри хука useEffect:

function CounterWithBug() {
  const [count, setCount] = useState(0);
useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []); // 🛑 missing the 'count' dependency
return <h1>Count is {count} </h1>;
}

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

Этот код может выглядеть отлично, но наше значение count не увеличивается. Вот демонстрационная ссылка, если вы хотите увидеть это в действии. Вы могли подумать, что обратный вызов setInterval вызывает сеттер, который должен увеличивать значение счетчика каждые 1 секунду. Но этого не происходит. Что нам не хватает?

2. Разъяснение причины ошибки

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

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

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

Рассмотрим пример:

let x = 10;
// function is created here (not invoked yet)
function bar() {
  console.log(x);
}
function foo() {
  let x = 50;
  bar(); // invocation happens here
}
foo(); // will print 10

При вызове foo будет напечатано 10, но не 50. Это связано с тем, что, когда bar создается ранее (этап создания функции), x сохраняется статически в своей области цепочка, и это разрешается, когда выполнение bar активируется позже.

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

function parent() {
  let x = 20;
  setTimeout(() => console.log(x), 1000);
}
parent(); // prints 20 after a minimun time delay of 1 sec.

Несмотря на то, что родительский контекст выполнения уничтожен, обратному вызову внутри интервала все же удается распечатать правильное значение x после задержки в 1 секунду. Это происходит из-за закрытия. Внутренняя функция статически во время создания захватывает переменные, определенные в родительской области.

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

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

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

function CounterWithBug() {
  const [count, setCount] = useState(0);
useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []); // 🛑 missing the 'count' dependency
return <h1>Count is {count} </h1>;
}

Когда эффект выполняется после первого рендеринга, анонимный обратный вызов внутри setInterval статически захватывает значение count из своего родительского контекста. Это происходит на этапе создания, и полученное значение равно 0. После минимальной задержки в 1 секунду вызывается этот обратный вызов, который, в свою очередь, вызывает сеттер с новым значением 1 (0 + 1). . В ответ на это React повторно визуализирует компонент, и вы можете увидеть новое значение count, равное 1, в пользовательском интерфейсе.

Теперь, когда массив зависимостей пуст, React создаст только новый эффект, заменяющий предыдущий, но никогда его не запустит. И поскольку мы только что узнали, что React всегда очищает предыдущие эффекты перед применением следующих эффектов, в этом случае он не утруждает себя запуском очистки. Следовательно, начальный интервал никогда не очищается, и наш анонимный обратный вызов все еще удерживает значение count, равное 0, в своей цепочке областей видимости. Когда вызывается сеттер, новое значение, передаваемое ему, всегда равно 1 (0 + 1). Вот почему значение count не увеличивается больше 1.

3. Никогда не лгите о зависимостях вашего эффекта - несколько исправлений

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

Да! вы угадали - массив зависимостей. Каждый раз, когда значение внутри массива зависимостей изменяется, React очищает предыдущий эффект и применяет новый.

Исправление 1. Использование "count" в качестве зависимости

В нашем примере кода с ошибками нам просто нужно передать переменную count в качестве зависимости для устранения проблемы. Вот демонстрационная ссылка.

function Counter() {
  const [count, setCount] = useState(0);
useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, [count]); // ✅ passing 'count' as dependency
  // will render the correct value of count
return <h1>Count is {count} </h1>;
}

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

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

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

Эффект никогда не должен лгать о своей зависимости.

Исправление 2: полное удаление массива зависимостей

Еще одно исправление для решения этой проблемы - полностью удалить массив зависимостей. Когда нет массива зависимостей, React обязательно выполнит процедуру очистки предыдущего эффекта перед запуском нового. И теперь, конечно, вы знаете, почему это имеет значение 😀

function Counter() {
  const [count, setCount] = useState(0);
// the following effect will run after the first render and after each update
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }); // ✅ No dependency array here.
  // will render the correct value of count
return <h1>Count is {count} </h1>;
}

Вот демонстрация в действии.

Исправление 3: использование функции «Updater» внутри установщика

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

Итак, можем ли мы просто установить интервал один раз и очистить его только при отключении нашего компонента? Единственный способ сделать это - передать пустой массив. Верно? Но затем мы снова сталкиваемся с той же проблемой, что и выше. Нам нужно снова передать переменную count.

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

function Counter() {
  const [count, setCount] = useState(0);
useEffect(() => {
    // ✅ No more dependency on `count` variable outside
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id);
  }, []);
return <h1>Count is : {count}</h1>;
}

Здесь мы используем функцию обновления внутри нашей функции установки, которая не зависит от внешней переменной count. При этом разрешите нам использовать пустой массив зависимостей. Мы не лжем React по поводу зависимости нашего эффекта. Это момент гордости 👏.

Исправление 4: на помощь приходит «useRef»

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

Давайте еще раз вернемся к нашему примеру кода в последний раз:

function CounterUsingRef() {
  const [count, setCount] = useState(0);
// ✅ putting fresh count into the latestCount
  const latestCount = useRef();
useEffect(() => {
    // ✅ make sure current always point to fresh value of count
    latestCount.current = count;
  });
useEffect(() => {
    const id = setInterval(() => setCount(latestCount.current + 1), 1000);
    return () => clearInterval(id);
  }, []);
return <h3>Counter with useRef: {count}</h3>;
}

Мы снова сдержали обещание не лгать о нашей зависимости. Наш эффект больше не зависит от переменной count.

Несмотря на то, что интервал по-прежнему статически захватывает объект latestCount (как и в случае первого ошибочного примера), React гарантирует, что изменяемый текущий всегда получает новое значение счетчика. 🙂

Вот демонстрация приведенного выше фрагмента кода, если вам интересно.

Заключение

Подведем итоги тому, что мы только что узнали:
1. функция, переданная в useEffect, будет разной при каждом рендеринге, и это поведение намеренно.
2. Каждый раз при повторном рендеринге мы планируем создать новый эффект, заменяющий предыдущий эффект.
3. Все функции на этапе создания статически фиксируют переменную, определенную в родительской области.
4. Мы должны никогда не лгать React о зависимостях нашего эффекта.

Я надеюсь, что эта статья была интересной для чтения и помогла вам понять, почему массив зависимостей играет важную роль в наших эффектах. Следовательно, я настоятельно рекомендую установить плагин ESLint под названием eslint-plugin-react-hook, который обеспечивает соблюдение этого правила.

Вот единственная ссылка на все демонстрации, объединенные в один файл. Следите за вторым исправлением и посмотрите, насколько оно медленнее, чем два последних исправления.

А теперь поделитесь этими знаниями с другими. Также не забудьте оставить несколько 👏, если статья вам понравилась. Удачного кодирования 😀