Главная » Хабрахабр » [Перевод] Полное руководство по useEffect

[Перевод] Полное руководство по useEffect

Вы написали несколько компонентов с использованием хуков. Возможно — даже создали небольшое приложение. В целом результат вас вполне устраивает. Вы привыкли к API и в процессе работы обнаружили несколько неочевидных полезных приёмов. Вы даже создали несколько собственных хуков и сократили свой код на 300 строк, поместив в них то, что раньше было представлено повторяющимися фрагментами программы. То, что вы сделали, вы показали коллегам. «Отлично получилось», — сказали они о вашем проекте.

Вам кажется, что вы что-то упускаете.
Но иногда, когда вы используете useEffect, составные части программных механизмов не особенно хорошо стыкуются друг с другом.
Пытаясь понять — что именно вас не устраивает, вы замечаете, что задаётесь следующими вопросами: Всё это похоже на работу с событиями жизненного цикла компонентов, основанных на классах… но так ли это на самом деле?

  • Как воспроизвести componentDidMount с помощью useEffect?
  • Как правильно загружать данные внутри useEffect? Что такое []?
  • Нужно ли указывать функции в виде зависимостей эффектов?
  • Почему иногда программа попадает в бесконечный цикл повторной загрузки данных?
  • Почему иногда внутри эффектов видно старое состояние или встречаются старые свойства?

Когда я только начал использовать хуки, меня тоже мучили эти вопросы. Даже когда я готовил документацию, я не мог бы сказать, что в совершенстве владею некоторыми тонкостями. С тех пор у меня было несколько моментов, когда я, вдруг поняв что-то важное, прямо-таки хотел воскликнуть: «Эврика!». О том, что я в эти моменты осознал, я и хочу вам рассказать. То, что вы узнаете сейчас о useEffect, позволит вам совершенно чётко разглядеть очевидные ответы на вышеприведённые вопросы.

Цель этой статьи не в том, чтобы дать её читателям некую пошаговую инструкцию по работе с useEffect. Но для того чтобы увидеть ответы на эти вопросы, нам сначала надо сделать шаг назад. И, честно говоря, тут не так много всего нужно изучить. Она нацелена на то, чтобы помочь вам, что называется, «грокнуть» useEffect. На самом деле, большую часть времени мы потратим на забывание того, что знали раньше.

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

«Ты должен забыть то, чему тебя учили»

habr.com/ru/company/ruvds/blog/445276/Йода

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

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

Ответы на вопросы

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

▍Как воспроизвести componentDidMount с помощью useEffect?

Хотя для воспроизведения функционала componentDidMount можно воспользоваться конструкцией useEffect(fn, []), она не является точным эквивалентом componentDidMount. А именно, она, в отличие от componentDidMount, захватывает свойства и состояние. Поэтому, даже внутри коллбэка, вы будете видеть исходные свойства и состояние. Если вы хотите увидеть самую свежую версию чего-либо, это можно записать в ссылку ref. Но обычно существует более простой способ структурирования кода, поэтому делать это необязательно. Помните о том, что ментальная модель эффектов отличается от той, что применима к componentDidMount и к другим методам жизненного цикла компонентов. Поэтому попытка найти точные эквиваленты может принести больше вреда, чем пользы. Для того чтобы работать продуктивно, нужно, так сказать, «думать эффектами». Основа их ментальной модели ближе к реализации синхронизации, чем к реагированию на события жизненного цикла компонентов.

▍Как правильно загружать данные внутри useEffect? Что такое []?

Вот хорошее руководство по загрузке данных с использованием useEffect. Постарайтесь прочитать его целиком! Оно не такое большое, как это. Скобки, [], представляющие пустой массив, означают, что эффект не использует значения, участвующие в потоке данных React, и по этой причине безопасным можно считать его однократное применение. Кроме того, использование пустого массива зависимостей является обычным источником ошибок в том случае, если некое значение, на самом деле, используется в эффекте. Вам понадобится освоить несколько стратегий (преимущественно, представленных в виде useReducer и useCallback), которые могут помочь устранить необходимость в зависимости вместо того, чтобы необоснованно эту зависимость отбрасывать.

▍Нужно ли указывать функции в виде зависимостей эффектов?

Рекомендовано выносить за пределы компонентов те функции, которые не нуждаются в свойствах или в состоянии, а те функции, которые используются только эффектами, рекомендуется помещать внутрь эффектов. Если после этого ваш эффект всё ещё пользуется функциями, находящимися в области видимости рендера (включая функции из свойств), оберните их в useCallback там, где они объявлены, и попробуйте снова ими воспользоваться. Почему это важно? Функции могут «видеть» значения из свойств и состояния, поэтому они принимают участие в потоке данных. Вот более подробные сведения об этом в нашем FAQ.

▍Почему иногда программа попадает в бесконечный цикл повторной загрузки данных?

Это может происходить тогда, когда загрузка данных выполняется в эффекте, у которого нет второго аргумента, представляющего зависимости. Без него эффекты выполняются после каждой операции рендеринга — а это значит, что установка состояния приведёт к повторному вызову таких эффектов. Бесконечный цикл может возникнуть и в том случае, если в массиве зависимостей указывают значение, которое всегда изменяется. Выяснить — что это за значение можно, удаляя зависимости по одной. Однако, удаление зависимостей (или необдуманное использование []) — это обычно неправильный подход к решению проблемы. Вместо этого стоит найти источник проблемы и решить её по-настоящему. Например, подобную проблему могут вызывать функции. Помочь решить её можно, помещая их в эффекты, вынося их за пределы компонентов, или оборачивая в useCallback. Для того чтобы избежать многократного создания объектов, можно воспользоваться useMemo.

▍Почему иногда внутри эффектов видно старое состояние или встречаются старые свойства?

Эффекты всегда «видят» свойства и состояние из рендера, в котором они объявлены. Это помогает предотвращать ошибки, но в некоторых случаях может и помешать нормальной работе компонента. В таких случаях можно для работы с такими значениями в явном виде использовать мутабельные ссылки ref (почитать об этом можно в конце вышеупомянутой статьи). Если вы думаете, что видите свойства или состояние из старого рендера, но этого не ожидаете, то вы, возможно, упустили какие-то зависимости. Для того чтобы приучиться их видеть, воспользуйтесь этим правилом линтера. Через пару дней это станет чем-то вроде вашей второй натуры. Кроме того, взгляните на этот ответ в нашем FAQ.

А теперь давайте подробнее поговорим о useEffect. Надеюсь, эти ответы на вопросы оказались полезными тем, кто их прочитал.

У каждого рендера есть собственные свойства и состояние

Прежде чем мы сможем обсуждать эффекты, нам надо поговорить о рендеринге.

Вот функциональный компонент-счётчик.

function Counter() times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );
}

Внимательно присмотритесь к строке <p>You clicked {count} times</p>. Что она означает? «Наблюдает» ли каким-то образом константа count за изменениями в состоянии и обновляется ли она автоматически? Такое заключение можно считать чем-то вроде ценной первой идеи того, кто изучает React, но оно не является точной ментальной моделью происходящего.

Это не некая магическая «привязка данных», не некий «объект-наблюдатель» или «прокси», или что угодно другое. В нашем примере count — это просто число. Перед нами — старое доброе число, вроде этого:

const count = 42;
// ... <p>You clicked {count} times</p>
// ...

Во время первого вывода компонента значение count, получаемое из useState(), равняется 0. Когда мы вызываем setCount(1), React снова вызывает компонент. В этот раз count будет равно 1. И так далее:

// Во время первого рендеринга
function Counter() { const count = 0; // Возвращено useState() // ... <p>You clicked {count} times</p> // ... } // После щелчка наша функция вызывается снова
function Counter() { const count = 1; // Возвращено useState() // ... <p>You clicked {count} times</p> // ... } // После ещё одного щелчка функция вызывается снова
function Counter() { const count = 2; // Возвращено useState() // ... <p>You clicked {count} times</p> // ... }

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

В результате эта строка не выполняет какую-то особую операцию привязки данных:

<p>You clicked {count} times</p>

Она лишь встраивает числовое значение в код, формируемый при рендеринге. Это число предоставляется средствами React. Когда мы вызываем setCount, React снова вызывает компонент с другим значением count. Затем React обновляет DOM для того чтобы объектная модель документа соответствовала бы самым свежим данным, выведенным в ходе рендеринга компонента.

Меняется компонент, который вызывается снова и снова. Самый главный вывод, который можно из этого сделать, заключается в том, что count является константой внутри любого конкретного рендера и со временем не меняется. Каждый рендер «видит» собственное значение count, которое оказывается изолированным для каждой из операций рендеринга.

В этом материале можно найти подробности о данном процессе.

У каждого рендера имеются собственные обработчики событий

До сих пор всё понятно. А что можно сказать об обработчиках событий?
Взгляните на этот пример. Здесь, через три секунды после нажатия на кнопку, выводится окно сообщения со сведениями о значении, хранящемся в count:

function Counter() { const [count, setCount] = useState(0); function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> <button onClick={handleAlertClick}> Show alert </button> </div> );
}

Предположим, я выполню следующую последовательность действий:

  • Доведу значение count до 3, щёлкая по кнопке Click me.
  • Щёлкну по кнопке Show alert.
  • Увеличу значение до 5 до истечения таймаута.

Увеличение значения count после щелчка по кнопке Show alert

Будет ли там выведено 5, что соответствует значению count на момент срабатывания таймера, или 3 — то есть значение count в момент нажатия на кнопку? Как вы думаете, что выведется в окне сообщения?

Сейчас вы узнаете ответ на этот вопрос, но, если хотите выяснить всё сами — вот рабочая версия этого примера.

Представьте себе приложение-чат, в котором, в состоянии, хранится ID текущего получателя сообщения, и имеется кнопка Send. Если то, что вы увидели, кажется вам непонятным — вот вам пример, который ближе к реальности. Собственно говоря, правильным ответом на вопрос о том, что появится в окне сообщения, является 3. В этом материале происходящее рассматривается в подробностях.

Механизм вывода окна сообщения «захватил» состояние в момент щелчка по кнопке.

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

Как же всё это работает?

Полагаю, стоит остановиться на этом подробнее. Мы уже говорили о том, что значение count является константой для каждого конкретного вызова нашей функции. Эта константа установлена в некое конкретное значение (представляющее собой состояние конкретной операции рендеринга). Речь идёт о том, что наша функция вызывается много раз (один раз на каждую операцию рендеринга), но при каждом из этих вызовов count внутри неё является константой.

Подобное поведение функций не является чем-то особенным для React — обычные функции ведут себя похожим образом:

function sayHi(person) { const name = person.name; setTimeout(() => { alert('Hello, ' + name); }, 3000);
} let someone = {name: 'Dan'};
sayHi(someone); someone = {name: 'Yuzhi'};
sayHi(someone); someone = {name: 'Dominic'};
sayHi(someone);

В этом примере внешняя переменная someone несколько раз переназначается. Такое же может произойти и где-то внутри React, текущее состояние компонента может меняться. Однако внутри функции sayHi имеется локальная константа name, которая связана с person из конкретного вызова. Эта константа является локальной, поэтому её значения в разных вызовах функции изолированы друг от друга! В результате, по прошествии тайм-аута, каждое выводимое окно сообщения «помнит» собственное значение name.

Если мы, работая с компонентами, применим тот же принцип, то окажется, что каждый рендер «видит» собственное значение count: Это объясняет то, как наш обработчик события захватывает значение count в момент щелчка по кнопке.

// Во время первого рендеринга
function Counter() { const count = 0; // Возвращено useState() // ... function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } // ... } // После щелчка наша функция вызывается снова
function Counter() { const count = 1; // Возвращено useState() // ... function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } // ... } // После ещё одного щелчка функция вызывается снова
function Counter() { const count = 2; // Возвращено useState() // ... function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + count); }, 3000); } // ... }

В результате каждый рендер, фактически, возвращает собственную «версию» handleAlertClick. Каждая из таких версий «помнит» собственное значение count:

// Во время первого рендеринга
function Counter() { // ... function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + 0); }, 3000); } // ... <button onClick={handleAlertClick} /> // Версия, хранящая значение 0 // ... } // После щелчка наша функция вызывается снова
function Counter() { // ... function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + 1); }, 3000); } // ... <button onClick={handleAlertClick} /> // Версия, хранящая значение 1 // ... } // После ещё одного щелчка функция вызывается снова
function Counter() { // ... function handleAlertClick() { setTimeout(() => { alert('You clicked on: ' + 2); }, 3000); } // ... <button onClick={handleAlertClick} /> // Версия, хранящая значение 2 // ... }

Именно поэтому в этом примере обработчики событий «принадлежат» конкретным рендерам, а когда вы щёлкаете по кнопке, компонент использует состояние count из этих рендеров.

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

Эта «мысленная» замена нам не повредит, так как константа count не может изменяться в пределах конкретного рендера. Надо отметить, что в вышеприведённом примере я встроил конкретные значения count прямо в функции handleAlertClick. Можно с уверенностью говорить о том, что так же можно размышлять и о других значениях, вроде объектов, но только в том случае, если мы примем за правило не выполнять изменения (мутации) состояния. Во-первых, это константа, во вторых — это число. При этом нас устраивает вызов setSomething(newObj) с новым объектом вместо изменения существующего, так как при таком подходе состояние, принадлежащее предыдущему рендеру, оказывается нетронутым.

У каждого рендера есть собственные эффекты

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

Рассмотрим пример из документации, который очень похож на тот, который мы уже разбирали:

function Counter() { const [count, setCount] = useState(0); useEffect(() => { document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );
}

Теперь у меня к вам вопрос. Как эффект считывает самое свежее значение count?

Может быть count — это мутабельная переменная, значение которой React устанавливает внутри нашего компонента, в результате чего эффект всегда видит её самую свежую версию? Может быть, тут используется некая «привязка данных», или «объект-наблюдатель», который обновляет значение count внутри функции эффекта?

Нет.

Даже обработчики событий «видят» значение count из рендера, которому они «принадлежат» из-за того, что count — это константа, находящаяся в определённой области видимости. Мы уже знаем, что в рендере конкретного компонента count представляет собой константу. То же самое справедливо и для эффектов!

Перед нами — сама функция эффекта, различная в каждой операции рендеринга. И надо отметить, что это не переменная count каким-то образом меняется внутри «неизменного» эффекта.

Каждая версия «видит» значение count из рендера, к которому она «принадлежит»:

// Во время первого рендеринга
function Counter() { // ... useEffect( // Функция эффекта из первого рендера () => { document.title = `You clicked ${0} times`; } ); // ... } // После щелчка наша функция вызывается снова
function Counter() { // ... useEffect( // Функция эффекта из второго рендера () => { document.title = `You clicked ${1} times`; } ); // ... } // После ещё одного щелчка функция вызывается снова
function Counter() { // ... useEffect( // Функция эффекта из третьего рендера () => { document.title = `You clicked ${2} times`; } ); // .. }

React запоминает предоставленную нами функцию эффекта, выполняет её после сброса значений в DOM и позволяет браузеру вывести изображение на экран.

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

Эффект, концептуально, можно представить в качестве части результатов рендеринга.

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

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

React:

  • Дай мне пользовательский интерфейс в условиях, когда в состояние записано число 0.

Компонент:

  • Вот результаты рендеринга: <p>You clicked 0 times</p>.
  • Кроме того, не забудь выполнить этот эффект после того, как завершишь работу: () => { document.title = 'You clicked 0 times' }.

React:

  • Конечно. Обновляю интерфейс. Эй, браузер, я добавляю кое-что в DOM.

Браузер:

  • Отлично, я вывел это на экран.

React:

  • Хорошо, теперь я запущу эффект, который мне дал компонент.
  • Выполняю () => { document.title = 'You clicked 0 times' }.

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

Компонент:

  • Эй, React, установи моё состояние в 1.

React:

  • Дай мне пользовательский интерфейс в условиях, когда в состояние записано число 1.

Компонент:

  • Вот результаты рендеринга: <p>You clicked 1 times</p>.
  • Кроме того, не забудь выполнить этот эффект после того, как завершишь работу: () => { document.title = 'You clicked 1 times' }.

React:

  • Конечно. Обновляю интерфейс. Эй, браузер, я изменил кое-что в DOM.

Браузер:

  • Отлично, я вывел изменения на экран.

React:

  • Хорошо, теперь я запущу эффект, который мне дал компонент.
  • Выполняю () => { document.title = 'You clicked 1 times' }.

Каждому рендеру принадлежит… всё

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

Рассмотрим следующий код: Попробуем выполнить мысленный эксперимент.

function Counter() { const [count, setCount] = useState(0); useEffect(() => { setTimeout(() => { console.log(`You clicked ${count} times`); }, 3000); }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> );
}

Что будет выведено в консоль в том случае, если быстро щёлкнуть по кнопке несколько раз?

Возможно, вам сейчас может показаться, что это простая задачка, и результат работы этого кода интуитивно понятен. Как обычно, сейчас мы рассмотрим ответ на этот вопрос. Мы увидим последовательность операций, выполняющих вывод в консоль, каждая из которых принадлежит конкретному рендеру, и, в результате, пользуется собственным значением count. Но это не так! Попробуйте поэкспериментировать с этим примером сами.

Щелчки по кнопке и вывод данных в консоль

Да и может ли эта программа вести себя иначе?». Тут вы можете подумать: «Конечно, именно так это и работает!

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

componentDidUpdate() { setTimeout(() => { console.log(`You clicked ${this.state.count} times`); }, 3000); }

Дело в том, что this.state.count всегда указывает на самое свежее значение count, а не на значение, принадлежащее конкретному рендеру. В результате, вместо последовательности сообщений с разными числами, мы, быстро щёлкнув по кнопке 5 раз, увидим 5 одинаковых сообщений.

Щелчки по кнопке и вывод данных в консоль

Дело в том, что истинным источником проблемы в этом примере является мутация (React выполняет изменение this.state в классах таким образом, чтобы это значение указывало бы на самую свежую версию состояния), а не механизм замыканий. Я вижу иронию в том, что хуки так сильно полагаются на JavaScript-замыкания, а компоненты, основанные на классах, страдают от традиционной проблемы, связанной с неправильным значением, которое попадает в коллбэк функции setTimeout, которую часто считаю обычной для замыканий.

Это облегчает их использование и размышления о них, так как, в сущности, речь идёт о константах. Замыкания — это отличный инструмент в том случае, если значение, которое «запирают» в замыкании, никогда не меняется. Да, кстати, версию этого примера, в которой используются компоненты, основанные на классах, можно исправить, воспользовавшись замыканием. И, как мы уже говорили, свойства и состояние никогда не меняются в конкретном рендере.

Плывём против течения

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

В результате следующие два компонента эквивалентны:

function Example(props) { useEffect(() => { setTimeout(() => { console.log(props.counter); }, 1000); }); // ... }
function Example(props) { const counter = props.counter; useEffect(() => { setTimeout(() => { console.log(counter); }, 1000); }); // ... }

При этом неважно, выполняется ли внутри компонента «заблаговременное» чтение из свойств или состояния. Они не изменятся! Внутри области видимости отдельно взятого рендера свойства и состояния не изменяются. Надо отметить, что деструктурирование свойств делает это более очевидным.

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

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

Вот версия нашего примера со счётчиком щелчков, основанная на функции, которая воспроизводит поведение той его версии, которая основана на классе:

function Example() { const [count, setCount] = useState(0); const latestCount = useRef(count); useEffect(() => { // Установить мутабельное значение в самое свежее состояние count latestCount.current = count; setTimeout(() => { // Прочитать мутабельное значение с самыми свежими данными console.log(`You clicked ${latestCount.current} times`); }, 3000); }); // ...

Щелчки по кнопке и вывод данных в консоль

Однако именно так сам React переназначает значение this.state в классах. Заниматься изменениями чего-либо в React может показаться странной идеей. По определению, менять это значение можно в любое время. В отличие от работы с захваченными свойствами и состоянием, у нас нет никакой гарантии того, что чтение latestCount.current даст один и тот же результат в разных коллбэках. Именно поэтому этот механизм не применяется по умолчанию, и для того, чтобы им воспользоваться, нужно сделать осознанный выбор.

Как насчёт очистки?

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

Рассмотрим этот код:

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

Предположим, props — это объект {id: 10} в первой операции рендеринга, и {id: 20} — во второй. Можно подумать, что тут происходит примерно следующее:

  • React выполняет очистку эффекта для {id: 10}.
  • React рендерит интерфейс для {id: 20}.
  • React выполняет эффект для {id: 20}.

(Но это, на самом деле, не совсем так.)

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

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

  • React рендерит интерфейс для {id: 20}.
  • Браузер выводит изображение на экран. Пользователь видит интерфейс для {id: 20}.
  • React выполняет очистку эффекта для {id: 10}.
  • React выполняет эффект для {id: 20}.

Тут вы можете задаться вопросом о том, как операция очистки предыдущего эффекта всё ещё может видеть «старое» значение props, содержащее {id: 10}, после того, как в props записано {id: 20}.

Надо отметить, что мы уже здесь были…

А может это — та же самая кошка?

Приведём цитату из предыдущего раздела: «каждая функция внутри механизма рендеринга компонента (включая обработчики событий, эффекты, тайм-ауты или вызовы API внутри них) захватывает свойства и состояние вызова рендера, который их определил».

В ходе операции очистки эффекта не производится чтение «самых свежих» свойств, что бы это ни значило. Теперь ответ очевиден! Эта операция читает свойства, которые принадлежат рендеру, в котором они определены:

// Первый рендер, в props записано {id: 10}
function Example() { // ... useEffect( // Эффект из первого рендера () => { ChatAPI.subscribeToFriendStatus(10, handleStatusChange); // Очистка для эффекта из первого рендера return () => { ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange); }; } ); // ... } // Следующий рендер, в props записано {id: 20}
function Example() { // ... useEffect( // Эффект из второго рендера () => { ChatAPI.subscribeToFriendStatus(20, handleStatusChange); // Очистка для эффекта из второго рендера return () => { ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange); }; } ); // ... }

Королевства будут расти и превращаться в пепел, Солнце сбросит внешние оболочки и станет белым карликом, последняя цивилизация исчезнет… Но ничто не заставит свойства, которые «увидела» операция очистки эффекта из первого рендеринга, превратиться во что-то, отличающееся от {id: 10}.

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

Синхронизация, а не жизненный цикл

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

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

function Greeting({ name }) { return ( <h1 className="Greeting"> Hello, {name} </h1> );
}

При его использовании совершенно неважно, будет ли сначала отрендерено <Greeting name="Dan" />, а потом — <Greeting name="Yuzhi" />, или если компонент просто сразу выведет <Greeting name="Yuzhi" />. И в том и в другом случаях в итоге мы увидим текст Hello, Yuzhi.

Если говорить о React, то справедливым окажется обратное утверждение. Говорят, что важен путь, а не цель. В этом и заключается разница между вызовами вида $.addClass и $.removeClass в jQuery-коде (это — то, что мы называем «путём»), и указание того, каким должен быть CSS-класс в React (то есть — того, какой должна быть «цель»). Здесь важна цель, а не то, каким путём к ней идут.

При рендеринге нет разницы между «монтированием» и «обновлением». React синхронизирует DOM с тем, что имеется в текущих свойствах и состоянии.

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

function Greeting({ name }) { useEffect(() => { document.title = 'Hello, ' + name; }); return ( <h1 className="Greeting"> Hello, {name} </h1> );
}

В этом состоит незначительное отличие восприятия useEffect от привычной ментальной модели, в которую входят понятия монтирования, обновления и размонтирования компонентов. Если вы пытаетесь создать эффект, который ведёт себя по-особому при первом рендеринге компонента, то вы пытаетесь плыть против течения! Синхронизация не удастся в том случае, если наш результат зависит от «пути», а не от «цели».

Хотя в процессе работы этих двух вариантов кода и могут быть некоторые временные различия (например, возникающие при загрузке каких-либо данных), в итоге конечный результат должен быть тем же самым. Не должно быть разницы между тем, выполняем ли мы рендеринг компонента сначала со свойством A, потом с B, а потом — со свойством C, и той ситуацией, когда мы сразу же рендерим его со свойством C.

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

Как с этим бороться?

Учим React различать эффекты

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

Предположим, у нас есть такой код:

<h1 className="Greeting"> Hello, Dan
</h1>

Мы хотим обновить его до такого состояния:

<h1 className="Greeting"> Hello, Yuzhi
</h1>

React видит два объекта:

const oldProps = {className: 'Greeting', children: 'Hello, Dan'};
const newProps = {className: 'Greeting', children: 'Hello, Yuzhi'};

React просматривает свойства этих объектов и выясняет, что значение children изменилось, для его вывода на экран нужно обновление DOM. При этом оказывается, что className осталось неизменным. Поэтому можно просто поступить так:

domNode.innerText = 'Hello, Yuzhi';
// domNode.className трогать не нужно

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

Например, возможно, компонент выполняет повторный рендеринг из-за изменения состояния:

function Greeting({ name }) { const [counter, setCounter] = useState(0); useEffect(() => { document.title = 'Hello, ' + name; }); return ( <h1 className="Greeting"> Hello, {name} <button onClick={() => setCounter(counter + 1)}> Increment </button> </h1> );
}

Но эффект не использует значение counter из состояния. Эффект синхронизирует document.title со свойством name, но свойство name тут не меняется. Перезапись document.title при каждом изменении counter кажется решением, далёким от идеального.

Может ли React просто… сравнить эффекты?

let oldEffect = () => { document.title = 'Hello, Dan'; };
let newEffect = () => { document.title = 'Hello, Dan'; };
// Может ли React увидеть то, что эти функции делают одно и то же?

На самом деле — нет. React не может догадаться о том, что именно делает функция, не вызывая её. (Исходный код не содержит конкретных значений. Он просто включает в себя свойство name.)

Именно поэтому, если нужно избежать ненужных перезапусков эффектов, эффекту можно передать массив зависимостей (такие массивы ещё называют deps), выглядящий как аргумент useEffect:

useEffect(() => { document.title = 'Hello, ' + name; }, [name]); // Наши зависимости

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

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

const oldEffect = () => { document.title = 'Hello, Dan'; };
const oldDeps = ['Dan']; const newEffect = () => { document.title = 'Hello, Dan'; };
const newDeps = ['Dan']; // React не может заглянуть в функцию, но он может сравнить зависимости. // Так как значения зависимостей остались прежними, новый эффект вызывать не нужно.

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

Не лгите React о зависимостях

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

function SearchResults() { async function fetchData() { // ... } useEffect(() => { fetchData(); }, []); // Нормально ли это? Не всегда. Есть лучшие способы написания такого кода. // ... }

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

Пока запомните: если вы указываете зависимости, то в массиве должны быть представлены все значения из компонента, которые используются эффектом. «Но я хочу запустить эффект только при монтировании!», — скажете вы. Сюда входят свойства, состояние, функции, то есть — всё, что находится в компоненте и используется эффектом.

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

Но, прежде чем мы перейдём к решению, давайте лучше вникнем в суть проблемы.

Что происходит в том случае, когда зависимости лгут React

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

useEffect(() => { document.title = 'Hello, ' + name; }, [name]);

Так как зависимости различаются — эффект перезапускается

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

useEffect(() => { document.title = 'Hello, ' + name; }, []); // Неправильно: в зависимостях нет name

Зависимости выглядят одинаково — эффект повторно не вызывается

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

Если использовать для его реализации класс, то внутреннее чутьё подскажет нам следующее: «Один раз настроить setInterval для запуска счётчика и один раз использовать clearInterval для его остановки». Например, предположим, мы создаём счётчик, который увеличивается каждую секунду. Когда мы, в голове, переносим подобный подход, планируя воспользоваться useEffect, то мы, инстинктивно, указываем в качестве зависимостей []. Вот пример реализации этого механизма. Запустить-то счётчик нам нужно лишь один раз, верно?

function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>;
}

Однако, вот незадача, в таком случае счётчик обновится лишь один раз.

Ведь вам нужно, чтобы эффект был вызван лишь один раз, так как в его коде вы, используя setInterval, запускаете счётчик. Если в голове у вас имеется модель, в соответствии с которой «зависимости позволяют мне указывать на то, когда я хочу повторно вызывать эффект», то этот пример может довести вас до экзистенциального кризиса. Почему же код работает не так, как ожидается?

А именно, эффект использует count, но мы не сообщили React правду об этом, указав, в качестве списка зависимостей, пустой массив. Но если вы знаете о том, что зависимости — это наша подсказка для React обо всём том, что эффект использует из области видимости рендера, то такое поведение этой программы вас не удивит. И когда эта ложь приведёт к проблемам — лишь вопрос времени.

В результате setCount(count + 1) в эффекте первого рендера означает setCount(0 + 1). В первой операции рендеринга count равняется 0. Так как мы никогда этот эффект повторно не вызываем, причиной чему — зависимости в виде [], каждую секунду будет вызываться setCount(0 + 1):

// Первый рендеринг, состояние равно 0
function Counter() { // ... useEffect( // Эффект из первого рендера () => { const id = setInterval(() => { setCount(0 + 1); // Всегда setCount(1) }, 1000); return () => clearInterval(id); }, [] // Никогда не перезапускается ); // ... } // В каждом следующем рендере состояние равно 1
function Counter() { // ... useEffect( // Этот эффект всегда игнорируется из-за того, что // мы солгали React о зависимостях, передав пустой массив. () => { const id = setInterval(() => { setCount(1 + 1); }, 1000); return () => clearInterval(id); }, [] ); // ... }

Мы солгали React, сообщив о том, что наш эффект не зависит от значений из компонента, хотя на самом деле — зависит.

Наш эффект использует count — значение, находящееся внутри компонента (но за пределами эффекта):

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

В результате указание пустого массива в качестве списка зависимостей приводит к ошибке. React сравнит зависимости и не станет повторно вызывать эффект.

Зависимости не меняются, поэтому вызов эффекта можно пропустить

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

Два подхода к честности при работе с зависимостями

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

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

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

Теперь массив зависимостей исправлен. Возможно, такое решение не идеально, но это — первая проблема, которую нам нужно решить. Теперь изменение count приведёт к перезапуску эффекта, каждый следующий вызов счётчика будет ссылаться на значение count из его рендера, выполняя операцию setCount(count + 1):

// Первый рендеринг, состояние равно 0
function Counter() { // ... useEffect( // Эффект из первого рендера () => { const id = setInterval(() => { setCount(0 + 1); // setCount(count + 1) }, 1000); return () => clearInterval(id); }, [0] // [count] ); // ... } // Второй рендер, состояние равно 1
function Counter() { // ... useEffect( // Эффект из второго рендера () => { const id = setInterval(() => { setCount(1 + 1); // setCount(count + 1) }, 1000); return () => clearInterval(id); }, [1] // [count] ); // ... }

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

Зависимости различаются, поэтому эффект мы перезапускаем

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

Рассмотрим несколько распространённых подходов избавления от зависимостей.

Делаем эффект самодостаточным

Итак, мы хотим избавиться от зависимости count в эффекте.

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

Для того чтобы это сделать, зададимся вопросом о том, для чего мы используем count. Возникает такое ощущение, что мы используем count только в вызове setCount. В таком случае нам, на самом деле, совершенно не нужно иметь count в области видимости. Когда мы хотим обновить состояние, основываясь на предыдущем состоянии, мы можем использовать функциональную форму обновления setState:

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

Я предпочитаю рассматривать подобные случаи как «ненастоящие зависимости». Да, значение count было необходимой зависимостью из-за того, что мы использовали внутри эффекта конструкцию setCount(count + 1). Однако count нам по-настоящему нужно лишь для того, чтобы преобразовать это значение в count + 1 и «вернуть» его React. Но React уже знает о текущем значении count. Всё, что нам нужно сообщить React — это сведения о том, что соответствующее значение состояния, в его текущем виде, нужно увеличить на единицу.

Её можно воспринимать как «отправку React инструкции», описывающей то, как должно изменяться состояние. Именно эту задачу и решает конструкция setCount(c => c + 1). Такая «форма обновления» оказывается полезной и в других случаях, например, если выполняется объединение множества обновлений.

И мы при этом не обманываем React. Обратите внимание на то, что мы, на самом деле, избавились от зависимости. Наш эффект больше не выполняет чтение значения count из области видимости рендера:

Зависимости не меняются, поэтому эффект повторно не вызывается

Испытать этот пример можно здесь.

Ему не нужно знать текущее значение count. Даже хотя этот эффект вызывается лишь один раз, коллбэк setInterval, который принадлежит первому рендеру, прекрасно справляется с отправкой инструкции c => c + 1 при каждом срабатывании таймера. React уже известно это значение.

Функциональные обновления и Google Docs

Помните, как мы говорили о том, что синхронизация — это основа ментальной модели эффектов? Интересным аспектом синхронизации является тот факт, что часто нужно, чтобы «сообщения», передаваемые между системами, не были бы привязаны к их состоянию. Например, правка документа в Google Docs не приводит к отправке всей страницы на сервер. Это было бы очень неэффективным решением. Вместо этого на сервер отправляется представление того, что попытался сделать пользователь.

Подобный подход способствует отправке из эффектов в компонент лишь минимально необходимого объёма информации. Хотя наш случай и отличается от вышеописанного, похожие рассуждения применимы и к эффектам. Она лишь описывает действие, которое нужно выполнить (то есть — «увеличение»). Использование функциональной формы системы обновления состояния, выраженной в виде setCount(c => c + 1), приводит к передаче гораздо меньшего объёма информации, чем использование конструкции вида setCount(count + 1), так как функциональная форма обновления состояния не «загрязнена» текущим значением count. Тот же принцип применим и при планировании обновлений. «Думать в стиле React» — значит искать минимально возможное представление состояния.

Хотя это — и не вполне точная аналогия, функциональные обновления играют в React похожую роль. Выражение в коде намерения (а не описание в нём результата) похоже на то, как Google Docs решает проблему совместного редактирования документов. Они позволяют обеспечить то, что обновления, исходящие из нескольких источников (обработчики событий, подписки эффектов, и так далее) могут быть корректно и предсказуемо применены в пакетном режиме.

Выглядит оно немного странно, да и возможности такой конструкции очень ограничены. Однако даже решение, в котором используется конструкция setCount(c => c + 1), нельзя признать безупречным. К счастью у setCount(c => c + 1) есть более мощный родственный паттерн. Например, нам это не поможет в том случае, когда в состоянии имеются две переменные, значения которых зависят друг от друга, или тогда, когда следующий вариант состояния нужно получить на основе свойств. Он называется useReducer.

Отделение обновлений от действий

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

function Counter() { const [count, setCount] = useState(0); const [step, setStep] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id); }, [step]); return ( <> <h1>{count}</h1> <input value={step} onChange={e => setStep(Number(e.target.value))} /> </> );
}

Вот рабочая версия этого примера.

Так как теперь в эффекте используется step, соответствующим образом изменён список зависимостей. Обратите внимание на то, что мы тут React не обманываем. И именно поэтому код выполняется правильно.

И, во многих случаях, это именно то, что нужно разработчику! Сейчас этот пример работает так: изменение step перезапускает setInterval — так как step является одной из зависимостей эффекта. Нет ничего плохого в том, чтобы разрушать то, что было создано средствами эффекта и создавать это заново, и мы, если только на то нет веской причины, не должны этого избегать.

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

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

Редьюсер позволяет отделять выражения «действий», которые происходят в компоненте, от того, как в ответ на них обновляется состояние. Когда вы обнаруживаете, что пишете нечто вроде setSomething(something => ...), это значит, что пришло время серьёзно подумать об использовании редьюсера вместо такого кода.

Поменяем зависимость нашего эффекта step на зависимость dispatch:

const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state; useEffect(() => { const id = setInterval(() => { dispatch({ type: 'tick' }); // Вместо setCount(c => c + step); }, 1000); return () => clearInterval(id);
}, [dispatch]);

Тут можно посмотреть этот код в деле.

Ответ заключается в том, что React гарантирует то, что функция dispatch будет неизменна в течение времени жизни компонента. Вы можете задать мне вопрос: «А чем это лучше того, что было?». Поэтому в вышеприведённом примере даже не нужно выполнять повторное создание таймера.

Мы решили проблему!

Но если их указать — делу это не повредит.) (Вы можете опустить значения dispatch и setstate и воспользоваться механизмом контейнеризации значений useRef для работы со значениями из зависимостей, так как React гарантирует то, что они будут статичными.

Это позволяет нашему эффекту оставаться отделённым от значения состояния step. Внутри эффекта, вместо считывания значений из состояния, выполняется диспетчеризация действия, которое описывает сведения о том, что произошло. Он просто сообщает нам о том, что произошло. Эффект не заботит то, как именно мы обновляем состояние. А логика обновления собрана в редьюсере:

const initialState = { count: 0, step: 1,
}; function reducer(state, action) { const { count, step } = state; if (action.type === 'tick') { return { count: count + step, step }; } else if (action.type === 'step') { return { count, step: action.step }; } else { throw new Error(); }
}

Вот, на тот случай, если вы не видели его раньше, полный код этого примера.

Использование useReducer — это чит-режим хуков

Мы узнали о том, как избавляться от зависимостей в том случае, когда эффекту нужно устанавливать значение переменной состояния, основываясь на предыдущей версии состояния или на другой переменной состояния. Но что если нам, для нахождения следующей версии состояния, нужны свойства? Например, возможно, наше API имеет вид <Counter step={1} />. Очевидно, в такой ситуации нельзя избежать указания props.step в качестве зависимости эффекта?

Редьюсер можно поместить в компонент, что позволит ему считывать значения свойств: На самом деле, избавиться от зависимостей можно и в этом случае!

function Counter({ step }) { const [count, dispatch] = useReducer(reducer, 0); function reducer(state, action) { if (action.type === 'tick') { return state + step; } else { throw new Error(); } } useEffect(() => { const id = setInterval(() => { dispatch({ type: 'tick' }); }, 1000); return () => clearInterval(id); }, [dispatch]); return <h1>{count}</h1>;
}

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

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

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

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

Перемещение функций в эффекты

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

Например, следующий код, вроде бы, выглядит рабочим:

function SearchResults() { const [data, setData] = useState({ hits: [] }); async function fetchData() { const result = await axios( 'https://hn.algolia.com/api/v1/search?query=react', ); setData(result.data); } useEffect(() => { fetchData(); }, []); // Нормально ли это? // ...

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

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

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

function SearchResults() { // Представим, что эта функция имеет большой размер function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=react'; } // Представим, что и код этой функции гораздо длиннее async function fetchData() { const result = await axios(getFetchUrl()); setData(result.data); } useEffect(() => { fetchData(); }, []); // ... }

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

function SearchResults() { const [query, setQuery] = useState('react'); // Представим, что эта функция имеет большой размер function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=' + query; } // Представим, что и код этой функции гораздо длиннее async function fetchData() { const result = await axios(getFetchUrl()); setData(result.data); } useEffect(() => { fetchData(); }, []); // ... }

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

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

function SearchResults() { // ... useEffect(() => { // Мы переместили эти функции внутрь эффекта! function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=react'; } async function fetchData() { const result = await axios(getFetchUrl()); setData(result.data); } fetchData(); }, []); // С зависимостями всё хорошо. // ... }

Вот рабочий вариант этого примера.

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

А значит — мы поймём, что query надо добавить в зависимости эффекта: Если позже мы отредактируем код getFetchUrl, решив воспользоваться там переменной состояния query, то мы, вероятнее всего, заметим, что редактируем код внутри эффекта.

function SearchResults() { const [query, setQuery] = useState('react'); useEffect(() => { function getFetchUrl() { return 'https://hn.algolia.com/api/v1/search?query=' + query; } async function fetchData() { const result = await axios(getFetchUrl()); setData(result.data); } fetchData(); }, [query]); // С зависимостями всё хорошо. // ... }

Вот демонстрационная версия этого примера.

Её наличие позволяет перезагрузить данные при изменении query. Добавляя эту зависимость, мы не просто «успокаиваем React». Это куда лучше, чем закрывать глаза на такие изменения до тех пор, пока подобное не вызовет ошибку. То, как устроены эффекты, принуждает программиста к тому, чтобы он замечал бы изменения в потоке данных и указывал бы на то, как эффекты должны их синхронизировать.

Другими словами, компьютер может сообщить программисту о том, какие изменения в потоке данных не обрабатываются компонентом правильно. Благодаря правилу линтера exhaustive-deps из плагина eslint-plugin-react-hooks можно анализировать код эффектов в процессе его ввода и видеть подсказки, касающиеся неуказанных зависимостей.

Линтер в действии

Это очень удобно.

Как быть, если поместить функцию внутрь эффекта нельзя?

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

Я думаю, что нельзя. Можно ли не указывать подобные функции в составе зависимостей эффекта? Обычно можно найти достойное решение подобной проблемы. Повторюсь: эффект не должен лгать React о зависимостях. Но, как мы уже видели, это совсем не так. Типичным заблуждением в подобной ситуации является мысль о том, что «функция никогда не изменится». На самом деле, функция, объявленная внутри компонента, изменяется в каждой операции рендеринга!

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

function SearchResults() { function getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query; } useEffect(() => { const url = getFetchUrl('react'); // ... Загрузим данные и что-то с ними сделаем... }, []); // Отсутствующая зависимость: getFetchUrl useEffect(() => { const url = getFetchUrl('redux'); // ... Загрузим данные и что-то с ними сделаем... }, []); // Отсутствующая зависимость: getFetchUrl // ... }

В подобной ситуации перемещение функции getFetchUrl в один из эффектов — не лучшая идея, так как это не позволит организовать её совместное использование несколькими эффектами.

Так как оба эффекта зависят от функции getFetchUrl (которая, в разных операциях рендеринга, представлена разными сущностями), массивы зависимостей оказываются бесполезными: С другой стороны, если быть «честным» при указании зависимостей, можно столкнуться с проблемой.

function SearchResults() { // Повторно вызывается в каждой операции рендеринга function getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query; } useEffect(() => { const url = getFetchUrl('react'); // ... Загрузим данные и что-то с ними сделаем ... }, [getFetchUrl]); // Зависимости настроены правильно, но они изменяются слишком часто. useEffect(() => { const url = getFetchUrl('redux'); // ... Загрузим данные и что-то с ними сделаем... }, [getFetchUrl]); // Зависимости настроены правильно, но они изменяются слишком часто. // ... }

Эту проблему так и хочется решить, просто исключив функцию getFetchUrl из списка зависимостей. Но я не думаю, что это — хорошее решение. Из-за этого сложнее будет ухватить тот момент, когда мы вносим в поток данных изменения, которые должны быть обработаны эффектом. Это ведёт к ошибкам наподобие той, связанной с неправильно работающим таймером, никогда не обновляющим данные, которую мы уже видели.

Вместо этого — вот два более простых варианта решения данной проблемы.

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

// Поток данных на эту функцию не влияет
function getFetchUrl(query) { return 'https://hn.algolia.com/api/v1/search?query=' + query;
} function SearchResults() { useEffect(() => { const url = getFetchUrl('react'); // ... Загрузим данные и что-то с ними сделаем... }, []); // С зависимостями всё в порядке. useEffect(() => { const url = getFetchUrl('redux'); // ... Загрузим данные и что-то с ними сделаем... }, []); // С зависимостями всё в порядке. // ... }

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

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

function SearchResults() { // Если зависимости не меняются, сущность сохраняется const getFetchUrl = useCallback((query) => { return 'https://hn.algolia.com/api/v1/search?query=' + query; }, []); // С зависимостями коллбэка всё в порядке. useEffect(() => { const url = getFetchUrl('react'); // ... Загрузим данные и что-то с ними сделаем... }, [getFetchUrl]); // С зависимостями эффекта всё в порядке. useEffect(() => { const url = getFetchUrl('redux'); // ... Загрузим данные и что-то с ними сделаем... }, [getFetchUrl]); // С зависимостями эффекта всё в порядке // ... }

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

Ранее наш пример выводил результаты поиска по двум запросам ('react' и 'redux'). Рассмотрим этот подход и поговорим о том, почему его применение целесообразно. В результате, вместо того, чтобы рассматривать query в виде аргумента функции, getFetchUrl теперь читает соответствующее значение из локального состояния. Но предположим, что мы хотим добавить в компонент поле ввода, которое позволяет пользователю приложения выполнять поиск по любому запросу, представленному свойством состояния query.

Попытавшись сделать это, мы тут же заметим отсутствие зависимости query в useCallback:

function SearchResults() { const [query, setQuery] = useState('react'); const getFetchUrl = useCallback(() => { // Нет аргумента query return 'https://hn.algolia.com/api/v1/search?query=' + query; }, []); // Отсутствующая зависимость: query // ... }

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

function SearchResults() { const [query, setQuery] = useState('react'); // Сущность не меняется до изменения query const getFetchUrl = useCallback(() => { return 'https://hn.algolia.com/api/v1/search?query=' + query; }, [query]); // Зависимости коллбэка в порядке. useEffect(() => { const url = getFetchUrl(); // ... Загрузим данные и что-то с ними сделаем... }, [getFetchUrl]); // Зависимости эффекта в порядке. // ... }

Благодаря использованию useCallback, если query не меняется, то и getFetchUrl не меняется, а значит, не происходит и перезапуска эффекта. Но если query меняется, тогда изменится и getFetchUrl, и мы выполним повторную загрузку данных. Это похоже на работу в Excel: если изменить значение в какой-то ячейке, то значения в других ячейках, зависящие от значения изменённой ячейки, будут автоматически пересчитаны.

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

function Parent() { const [query, setQuery] = useState('react'); // Сущность не меняется до изменения query const fetchData = useCallback(() => { const url = 'https://hn.algolia.com/api/v1/search?query=' + query; // ... Загрузим данные и вернём их ... }, [query]); // С зависимостями коллбэка всё в порядке return <Child fetchData={fetchData} />
} function Child({ fetchData }) { let [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, [fetchData]); // С зависимостями эффекта всё в порядке // ... }

Так как fetchData из Parent изменяется лишь при изменении значения состояния query, Child не будет выполнять перезагрузку данных до тех пор, пока это не будет нужно приложению.

Являются ли функции частью потока данных?

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

class Parent extends Component { state = { query: 'react' }; fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query; // ... Загрузим данные и что-то с ними сделаем... }; render() { return <Child fetchData={this.fetchData} />; }
} class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } render() { // ... }
}

Возможно, вы думаете сейчас: «Да ладно, Дэн, все мы знаем, что useEffect — это нечто вроде комбинации componentDidMount и componentDidUpdate. Хватит уже об этом говорить!». Однако работать это не будет даже при использовании componentDidUpdate:

class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { // Это условие никогда не будет истинным if (this.props.fetchData !== prevProps.fetchData) { this.props.fetchData(); } } render() { // ... }
}

Конечно, fetchData — это метод класса! (Или, скорее, свойство класса, но это ничего не меняет.) Этот метод не изменится только из-за того, что изменилось состояние. Поэтому this.props.fetchData будет оставаться равным prevProps.fetchData и повторная загрузка данных никогда выполнена не будет. Тогда, может быть, уберём условие?

componentDidUpdate(prevProps) { this.props.fetchData(); }

Но здесь тоже не всё благополучно. Теперь загрузка данных будет выполняться при каждом повторном рендеринге компонента. (Интересным способом подтвердить это будет добавление анимации.) Может быть, надо привязать fetchData к значению this.state.query?

render() { return <Child fetchData={this.fetchData.bind(this, this.state.query)} />; }

Но тогда условие this.props.fetchData !== prevProps.fetchData всегда будет давать true, даже в том случае, если query не меняется! В результате мы постоянно будем выполнять повторную загрузку данных.

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

class Parent extends Component { state = { query: 'react' }; fetchData = () => { const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query; // ... Загрузим данные и что-то с ними сделаем ... }; render() { return <Child fetchData={this.fetchData} query={this.state.query} />; }
} class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { if (this.props.query !== prevProps.query) { this.props.fetchData(); } } render() { // ... }
}

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

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

Мы можем сказать, что, если входные данные функции изменились, то и сама функция изменилась. Функции могут по-настоящему включаться в поток данных благодаря использованию useCallback. Благодаря особенностям useCallback изменения свойств наподобие props.fetchData могут распространяться автоматически. Если же этого не произошло, то неизменной осталась и функция.

Аналогично, useMemo позволяет делать то же самое со сложными объектами:

function ColorPicker() { // Не нарушает неглубокую проверку на равенство свойств компонента Child, // система реагирует лишь на реальное изменение цвета. const [color, setColor] = useState('pink'); const style = useMemo(() => ({ color }), [color]); return <Child style={style} />;
}

Мне хотелось бы подчеркнуть то, что если всюду использовать useCallback, это сделает код довольно-таки громоздким. Этот механизм представляет собой хороший «запасной выход», он полезен в тех случаях, когда функция и передаётся дочерним компонентам, и вызывается внутри их эффектов. Или в тех случаях, когда нужно предотвратить нарушение мемоизации дочернего компонента. Но хуки лучше отражают модель системы, в которой полностью избегают передачи коллбэков дочерним компонентам.

Я стремлюсь к тому, чтобы эффекты были бы простыми, и коллбэки в них этому не способствуют. В вышеприведённых примерах я предпочитаю, чтобы функция fetchData присутствовала бы либо внутри эффекта (который можно преобразовать в собственный хук), либо была бы представлена импортированной извне сущностью. («Что если коллбэк props.onComplete изменится в то время, пока на отправленный запрос не получено ответа?») Можно имитировать поведение класса, но это не решит проблему состояния гонки.

Состояние гонки

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

class Article extends Component { state = { article: null }; componentDidMount() { this.fetchData(this.props.id); } async fetchData(id) { const article = await API.fetchArticle(id); this.setState({ article }); } // ... }

Как вы, возможно, знаете, этот код содержит ошибки. Он не поддерживает обновления. А вот — ещё один подобный пример, который можно найти в интернете:

class Article extends Component { state = { article: null }; componentDidMount() { this.fetchData(this.props.id); } componentDidUpdate(prevProps) { if (prevProps.id !== this.props.id) { this.fetchData(this.props.id); } } async fetchData(id) { const article = await API.fetchArticle(id); this.setState({ article }); } // ... }

Этот код, определённо, лучше, но в нём всё ещё есть проблемы. Причина этого заключается в том, что запросы могут идти не по порядку. Например, я загружаю статью с {id: 10}, потом перехожу на статью с {id: 20}, выполняя ещё один запрос, и ответ на этот запрос приходит до прихода ответа на первый запрос. В результате запрос, который начался раньше, но ответ на который пришёл позже, перезапишет состояние. А это неправильно.

Это — ситуация, типичная для кода, в котором конструкция async/await (применение которой означает, что нечто ожидает какого-то результата) смешивается с потоком данных, направленным сверху вниз (свойства и состояние не могут изменяться в то время, когда мы находимся в асинхронной функции). То, о чём мы тут говорим, называется состоянием гонки.

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

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

Кроме того, простейшим временным решением этой проблемы является контроль асинхронных операций с помощью логических переменных:

function Article({ id }) { const [article, setArticle] = useState(null); useEffect(() => { let didCancel = false; async function fetchData() { const article = await API.fetchArticle(id); if (!didCancel) { setArticle(article); } } fetchData(); return () => { didCancel = true; }; }, [id]); // ... }

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

Поднимаем планку

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

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

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

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

После того, как вы освоитесь с этими инструментами, вы не особенно часто будете прибегать к использованию useEffect. Я видел, как в различных приложениях создаются их собственные хуки, наподобие useFetch, который инкапсулирует некоторую логику аутентификации таких приложений, или useTheme, который использует контекст темы. Но гибкость, предоставляемая этим механизмом, идёт на пользу каждому хуку, построенному на его основе.

Но загрузка данных — это не совсем то, что относится к проблеме синхронизации. До сих пор, например, useEffect наиболее часто используется для загрузки данных. Что мы вообще синхронизируем с их помощью? Это особенно очевидно по той причине, что зависимости в таких случаях обычно представлены пустым массивом.

В долгосрочной перспективе применение механизма Suspense для загрузки данных даст сторонним библиотекам отличный способ сообщить React о том, что рендеринг надо приостановить до тех пор, пока что-то асинхронное (что угодно: код, данные, изображения) не будет готово к выводу.

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

Итоги

Теперь вы знаете об эффектах практически всё, что знаю я. И если вы, начиная читать этот материал и просмотрев раздел с ответами на вопросы, столкнулись с чем-то непонятным, теперь, надеюсь, всё встало на свои места.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Слушаем SID-музыку через OPL3 на современных ПК

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

Пользователь в Docker

В новой статье он рассказывает, как создать пользователей в Docker. Андрей Копылов, наш технический директор, любит, активно использует и пропагандирует Docker. Правильная работа с ними, почему пользователей нельзя оставлять с root правами и, как решить задачу несовпадения идентификаторов в Dockerfile. Это кажется очень удобно, ведь ...