Привет, друзья! В данной статье мы с вами разработаем кастомный хук, функционал которого будет аналогичен функционалу встроенного хука useEffect, за исключением того, что наш useEffect будет повторно выполняться только при изменении его зависимостей любого типа (неважно, примитивы это или объекты). Предполагается, что вы хорошо знакомы с тем, как работает хук useEffect, а также с тем, когда и почему происходит повторный рендеринг React-компонентов. Если нет, вот парочка ссылок: Этого должно быть достаточно для понимания того, о чем мы будем говорить. В дальнейшем будет приведено еще несколько ссылок для более глубокого погружения в тему. Код проекта, с которым мы будем работать, можно найти здесь. Начнем с примера. Предположим, что у нас имеется такой компонент: Этот компонент принимает 2 пропа: Данный компонент рендерит значение счетчика и запускает эффект при первом рендеринге и при каждом изменении объекта-пропа. В эффекте в консоль инструментов разработчика в браузере выводится сообщение effect. Допустим, что этот компонент используется следующим образом: Вопрос: будет ли эффект в Component выполняться при изменении значения счетчика? Первое, что приходит на ум: эффект не зависит от значения счетчика, а значение объекта не меняется, поэтому эффект выполняться не будет. Давайте это проверим. Обратите внимание: в строгом режиме при разработке хуки вызываются дважды. Для чистоты эксперимента строгий режим лучше отключить: Запускаем приложения, открываем консоль и 2 раза нажимаем кнопку для увеличения значения счетчика: 
 Что мы видим? Мы видим 3 сообщения effect. Это означает, что эффект в Component выполняется при каждом изменении значения счетчика! Но почему? Все просто: в компоненте App при каждом рендеринге (изменении значения счетчика — изменение состояния компонента, влекущее его повторный рендеринг) создается новый объект object, который передается Component в качестве пропа. Объекты, в отличие от примитивов, сравниваются не по значениям, а по ссылкам на выделенную для них область в памяти. Новый объект — новая ссылка на область памяти. Поэтому в данном случае при сравнении объектов всегда возвращается false, и эффект перезапускается. Как можно решить данную проблему? Существует несколько способов. Первое, что можно сделать — это разложить объект на примитивы с помощью синтаксиса spread (...): Это потребует внесения следующих изменений в Component: Поскольку теперь значением зависимости эффекта является строка value, которая не меняется, эффект при изменении значения счетчика повторно не выполняется, в чем можно убедиться, повторив эксперимент: 
 Кажется, что задача успешно решена, и можно на этом закончить, но такое решение подходит только для небольших и одномерных объектов. Мы должны точно знать, какие свойства имеет объект, определить тип каждого свойства, не забыть указать нужное свойство в качестве зависимости эффекта и т.д. Если наш объект будет выглядеть так: То при его распаковке значением пропа будет { nested: 'value' } (объект), и решение работать не будет. Это определенно не тот результат, к которому мы стремились, поэтому двигаемся дальше. Следующее, что приходит на ум — а почему бы не конвертировать объекты в строки? Тогда сравниваться будут уже не объекты, а примитивы. У нас для этого даже есть специальный встроенный метод — JSON.stringify. Это потребует внесения следующих изменений в Component: Обратите внимание: в качестве пропа Component снова передается объект. В данном случае результатом стрингификации будет строка {"some":"value"}. Поскольку эта строка будет прежней при изменении значения счетчика, эффект повторно выполняться не будет (повторите эксперимент самостоятельно — результат должен быть аналогичен предыдущему). Теперь-то задача решена и можно успокоиться? — спросите вы. Не совсем. Основная проблема использования JSON.stringify(), впрочем, как и JSON.parse() состоит в том, что данные операции выполняются очень медленно. Вот один из многих топиков на StackOverflow, где обсуждается эта проблема. Чем больше и сложнее объект, тем медленнее его стрингификация и парсинг. В нашем случае JSON.stringify будет вызываться при каждом рендеринге Component. Таким образом, данное решение также не является панацеей. Двигаемся дальше. Обратите внимание: существуют специальные npm-пакеты для быстрой стрингификации объектов, например, fast-json-stable-stringify, но зачем нам в проекте сторонние библиотеки, если можно обойтись без них? Какие еще инструменты предоставляет React для обеспечения стабильности объектов? Одним из таких инструментов является хук useMemo. Попробуем мемоизировать объект до его передачи в качестве пропа Component. Это потребует внесения следующих изменений в App: Данное решение работает (повторите эксперимент самостоятельно), но у него также имеются некоторые недостатки: Слишком много ограничений. Такой вариант нам не подходит. Что же делать? Реализовать кастомный useEffect. Подумаем о том, каким требованиям должен удовлетворять наш хук. Начнем с функции для сравнения зависимостей. Классическим вариантом является использование функции isEqual из библиотеки Lodash. Существует отдельный npm-пакет — lodash.isequal, но мы не хотим устанавливать дополнительные зависимости, поэтому реализуем эту функцию самостоятельно: Если хотите, можете самостоятельно сравнить скорость выполнения areEqual() и JSON.stringify(). Вы увидите, что areEqual() гораздо быстрее, особенно в случае больших и сложных объектов. Реализуем кастомный хук для хранения предыдущих зависимостей: Придумаем название основному хуку. useEffect сравнивает зависимости поверхностно (shallow). Мы проводит глубокое (deep) сравнение. Почему бы не назвать хук useDeepEffect? Приступаем к его реализации: Сохраняем зависимости: Определяем иммутабельную переменную для индикатора первоначального рендеринга: Определяем состояние для индикатора необходимости повторного вызова коллбека: В данном случае мы используем технику принудительного ререндеринга компонента по причине изменения его состояния: В этом случае компонент будет принудительно подвергнут повторному рендерингу. Определяем эффект для сравнения зависимостей и обновления соответствующего индикатора при необходимости: Наконец, определяем эффект для выполнения коллбека: Обратите внимание: все операции можно выполнять в одном useEffect, но мы будем придерживаться принципа единственной ответственности (single responsibility). Также обратите внимание, что функцию areEqual можно применять только в отношении объектов, но, кажется, что это будет экономией на спичках. Полный код хука useDeepEffect вместе с функцией сравнения и хуком usePrevious: Вносим следующие изменения в Component: Повторяем эксперимент: 
 Как видим, при изменении значения счетчика эффект повторно не вызывается. Будет ли это работать в случае вложенного объекта: Результат аналогичен предыдущему. Добавим count в object: Теперь эффект запускается при каждом изменении значения счетчика: 
 Такой же результат мы получим, если уберем count из object, но укажем count в качестве зависимости useDeepEffect: Что если мы добавим в object функцию increaseCount? 
 Эффект выполняется при каждом изменении значения счетчика. Точнее, при каждом рендеринге App создается новая функция increaseCount — новый особый (!) объект. При сравнении таких объектов функция areEqual всегда возвращает false. Решить данную проблему можно посредством мемоизации increaseCount() с помощью хука useCallback: Последний момент, на который хотелось бы обратить ваше внимание: передача объекта в качестве пропа всегда приводит к повторному рендерингу компонента: Теперь Component в качестве пропа передается только object. Будет ли он рендериться при изменении значения счетчика? 
 Как видим, именно это и происходит. Но почему? Все просто: рендеринг родительского компонента (App) влечет безусловный рендеринг всех его потомков. Сам по себе повторный рендеринг не является проблемой до тех пор, пока речь не идет о каких-то сложных вычислениях или обновлении DOM. Однако, при желании, данную проблему можно решить с помощью компонента высшего порядка (Higher Order Component — HOC) React.memo. Но просто обернуть в него Component недостаточно: В данном случае Component все равно будет повторно рендериться при каждом рендеринге App. Почему? Потому что React.memo() сравнивает пропы поверхностно: с примитивами все хорошо, а при сравнении (немемоизированных) объектов (object) всегда возвращается false. В качестве второго опционального параметра React.memo() принимает функцию сравнения предыдущих и новых (следующих) пропов. Предыдущие и новые пропы — это объекты: 
 Следовательно, нам необходимо сравнивать объекты. Здесь нам на помощь снова приходит функция areEqual: 
 Как видим, Component больше не подвергается ререндерингу при повторном рендеринге App. В данном случае это также предотвратит повторный запуск эффекта. Поиграть с кодом можно здесь: Пожалуй, это все, чем я хотел поделиться с вами в этой статье. Надеюсь, вы узнали что-то новое и не зря потратили время. Благодарю за внимание и happy coding!  JavaScript Developer