В 16.8 версии библиотеки React впервые появились хуки (hooks) — функции, которые упрощают работу с компонентами React и переиспользованием какой-либо логики. В экосистеме React уже есть много дефолтных хуков, но также можно создавать и свои. Я Михаил Карямин, фронтенд-разработчик в Учи.ру, расскажу, как и в каких случаях хуки в React облегчают жизнь разработчику и как с ними работать.
React без хуков и с ними
Чтобы понять, почему хуки упрощают жизнь разработчику, надо посмотреть на то, как писался React раньше. Он был на классовых компонентах: есть стандартный метод рендер, который отвечает за разметку, есть поле state, где хранится объект и все его состояния, есть какие-то свои методы и есть методы жизненных циклов. Всё это выглядит очень громоздко, и практически не актуально.
Многие сегодняшние проекты React пишутся уже на функциональных компонентах. Есть функция, которая возвращает разметку, и внутри функции есть хуки для хранения состояния (state) или хуки для логики.
Важно: несмотря на то что React в классовом виде встречается редко, почитать, как это работает, будет все же не лишним. Возможно, вам придется поддерживать написанный таким способом проект.
До хуков в классовых компонентах для хранения общей переиспользуемой логики самыми распространенными вариантами были так называемые higher-order component (HOC). Это функция, которая оборачивает обычные классовые компоненты. В качестве аргумента она принимает компонент, к которому нужна какая-то переиспользуемая логика. HOC тяжело читаются, во время учебы я долго не мог понять, как тут всё взаимосвязано и куда что передается.
Сегодня вместо HOC используются хуки. Компонент с хуком смотрится намного компактнее и понятнее: вся логика занимает одну строчку «const [loading, data] = useFetch(MOVIE_URI)» — хук возвращает текущее состояние и данные. А если нужны несколько переиспользуемых бизнес-логик, можно просто добавить еще одну строчку и появится дополнительный компонент. В случае с HOC пришлось бы оборачивать компоненты: это не очень красиво и тяжело читается.
Как пишется собственный хук
Первая итерация
Предлагаю попробовать написать хук и посмотреть, как работает концепт React в рамках одного хука. Для этого возьмем базовый useState, отвечающий за состояние.
Заводим обычную переменную someState, задаем первоначальное значение — пусть будет «0». Добавляем функцию, которая будет ее увеличивать на 1 и возвращать актуальное состояние к переменной. Проверяем в console.log: пишем increaseState и вызываем несколько раз. Все работает, state обновляется — пошел отсчет 1, 2, 3, 4, 5.
Одна проблема: state не защищен, его легко сломать случайно или намеренно. Например, напишем someState = 100. И вместо ожидаемой «5» получим «102». Если бы строчек было много, долго бы пришлось искать, где баг. Чтобы это исправить, переменную надо инкапсулировать, поместить в функцию. Но теперь JS ругается, так как someState оказался в области видимости функции, а не в глобальной.
Исправляем синтаксическую ошибку, и теперь state не обновляется, поскольку при каждом вызове функции у нас идет инициализация переменной, и она всегда равна нулю. Для решения проблемы будем вместо переменной возвращать функцию, которая имеет доступ к нашей внутренней переменной. Раз теперь функция не просто увеличивает state, а возвращает функцию, ее стоит переименовать в getIncreaseState и добавить переменную, в которой будет записан increaseState.
На этом этапе мы получили реализацию функции, которая может хранить какой-то state и изменять его.
Вторая итерация
Теперь надо написать функцию useState. Она принимает в качестве аргумента первоначальное состояние – initialValue. Хук возвращает массив, который состоит из двух элементов: сначала state, а потом функцию, которая изменяет setState.
Добавляем в тело функции переменную value, где будем хранить значение, и заведем константу под state — она равна value. Объявим функцию, которая будет изменять наш state и принимать newValue. И внутри этой функции просто переписываем value на newValue.
Чтобы проверить работоспособность, вводим count – state, и setCount — функция, которая будет менять наш state. Count — «0», как initialValue. Меняем его на «2», но переменная в console.log не меняется.
В чем проблема? При деструктуризации массива в JS создаются константы. Наш count «0» записался на 29 строке и не изменится, так как доступа к внутреннему состоянию функции state у нас нет. Count «2» на 32 строке — это новая переменная, внутреннее состояние мы не видим.
Самый простой способ это исправить — вместо переменной из useState возвращать функцию, у которой в момент вызова в замыкании есть value. Так мы увидим внутреннее состояние useState. Для этого прописываем обычную стрелочную функцию и меняем переменные: вместо count поставим getCount. Теперь состояние обновляется.
Функция уже выглядит почти как хук, но вместо обычной переменной первым элементом она возвращает функцию. А нам надо написать полный аналог хука — дефолтного useState.
Третья итерация
Заведем немедленно вызываемую функцию (IIFE) и назовем ее React. В ее теле будет уже написанный хук, только без функции, возвращающей значение, и добавим в тело React переменную value без изначального значения. State будет «value || initialValue». Возвращать будем state, а из функции React возвращаем объект. Первое поле этого объекта как раз наш прототип хука — useState.
Напишем компонент — назовем Component и добавим внутрь хук, который будет записывать имя: вносим name и setName. Первоначальное значение — «Mike». Если бы у нас было взаимодействие с реальным DOM-ом, функция бы отрисовывала нам изменения в разметку. Но его нет, поэтому будем возвращать функцию render и выводить текущее состояние в console.log.
Также мы будем возвращать импровизированное взаимодействие пользователя с каким-то input: например, с формой ввода имени. Называем ее changeName. В качестве аргумента она принимает новое имя, которое вводит пользователь, и передает в хук.
Получился прототип компонента, который осталось связать с React. Для этого добавляем функцию, называем ее render, в качестве аргумента она принимает Сomponent. В ее теле вызывается сomponent, у него будет вызываться метод render, который отрисовывает консоль и возвращает component.
Заводим переменную: называем app, связываем с React и отрендерим компонент — «Mike» вывелось в консоли. Пробуем поменять имя через App.changeName, подставляем «Vasya», делаем новый render. Все четко, «Vasya» вывелся в консоли. Реализация работает, есть прототип React и компонента. Но в реальном приложении мы часто используем несколько раз в одном компоненте, поэтому будем усложнять задачу.
Добавим фамилию и взаимодействие с inpit в return — changeSurname. Но теперь в консоли при изменении имени у нас меняется и фамилия. Если фамилию меняем — аналогично. А должно только имя или только фамилия.
Где проблема? Ответ кроется в функции React. Во 2 строчке есть переменная, в которую записываются все вызовы useState, и когда вызов был 1, все отлично. Но как только их становится 2, текущий value перезаписывается. Нужно завести в хук массив states вместо переменной value и добавить переменную index, потому что хук вызываем несколько раз, и надо понимать, какой вызов какому элементу массива принадлежит.
Если в хук завести console.log, можно посмотреть, что хранится в массиве. Оказывается, вместо 1 элемента здесь 2. Надо добавить увеличение индекса, который при каждом вызове должен прибавлять единицу, и сделать сбрасывание индекса при каждом рендере. Иначе каждый раз при срабатывании хука предыдущее состояние будет не перезаписываться, а добавляться в конец массива. В итоге будет расти количество элементов в массиве.
Остается последняя проблема, связанная с замыканием. Из хука мы возвращаем не вызов функции, а просто ссылку на нее. Когда у нас происходит функция render, хук вызывается дважды: сначала он 0, потом 1. На следующем вызове, с changeSurname, он уже 2. Потому что замыкание — это все переменные, доступные в момент вызова функции.
Чтобы это исправить, надо сохранить актуальное состояние индекса внутри useState в момент инициализации. И когда мы вызовем функцию setState, мы уже будем брать не глобальный индекс, который равен 2, а будем использовать именно внутренний, так как он будет верный.
Теперь в консоли все работает, и у нас есть простой функциональный хук.
Кастомные хуки и их применение
Все кастомные хуки состоят из дефолтных: useState, useEffect, useReducer, useMemo, useCallback. Их можно классифицировать в зависимости от проекта и бизнес-задач. Но я делю кастомные хуки по принципу использования на 6 категорий.
Первая — listeners. Это обширная группа, к которой можно отнести хуки, которые ловят клик пользователя, положение экрана мобильного устройства, геолокацию и так далее.
Вторая — UI хуки. Они нужны для работы с CSS, с аудио, с видео.
Третья — side-effects. Хуки, которые работают вне основного потока приложения. Например, они нужны для работы с асинхронностью, с local storage, для изменения title страницы.
Четвертая — lifecycles. В классовых компонентах очень много инструментов для работы с жизненными циклами, а в функциональных есть только useEffect. Так что приходится часто дописывать хуки: например, useMount, который срабатывает только при монтировании, или useUpdate, который имитирует работу компонента DidUpdate.
Пятая — state. Хуки для удобной работы с состоянием отдельных компонентов и с глобальным состоянием. Такие хуки есть, например, в Redux.
Шестая — animations. Хуки для работы с request animation frame, интервалом, таймаутом. Самая непопулярная группа.
Эта разбивка очень условная, жестких границ у групп нет: в той же категории UI могут быть не только хуки для CSS, аудио и видео.
Как это все выглядит на практике: возьмем хук, отвечающий за переключение темы на сайте или в приложении. Часто это переключение происходит или от системных настроек, или от отдельной кнопки. Чтобы сменить тему, нам нужен компонент с useDarkMode — хук, который возвращает переключение, включение, выключение и текущее состояние.
Под капотом у него несколько вспомогательных хуков:
useMediaQuery — помогает узнать, какая предпочтительная тема у пользователя. Он состоит из дефолтных useState и useEffect. В первой строчке прописываем текущее состояние, а во второй создаем функцию Callback и подписываемся на изменение медиавыражения — чтобы всегда иметь актуальное состояние;
useIsFirstRender — основан на useRef. При первом его срабатывании мы заходим в условия и переписываем из isFirstRender current = false. При ре-рендере этот хук вернет false, и мы попадем, куда нам нужно;
useUpdateEffect — он почти аналогичен стандартному useEffect, но не срабатывает при первом рендере. В классовых компонентах был componentDidUpdate, в функциональных его нет, и приходится придумывать что-то для замены.
Кроме этого useDarkMode использует дефолтный хук useCallback. Благодаря ему при перерисовке у нас сохранится ссылка на функцию, которую мы обернули. При перерисовке в React у нас происходит переинициализация функции, и без useCallback наш оптимизированный компонент посчитает, что у нас изменился prop, и сам перерисуется.
В итоге у нас простая цепочка: в теле хука useMediaQuery показывает, какую тему предпочитает пользователь. Потом хук useLocalStorage помогает внести его выбор в local storage, и при перегрузке страницы не будет морганий — нужная тема сразу включится. Дальше если у пользователя обновится предпочтение, сработает useUpdateEffect. А функция возвращает 3 Callback и текущее состояние.
Тонкости useEffect
Хук useEffect — один из самых любопытных, он заменяет 3 метода жизненного цикла в классовых. Предлагаю разобрать один из распространенных кейсов с useEffect.
Есть компонент, в котором мы что-то отрисовываем на основе данных, полученных с сервера. Основные props – это name, surname и number. Меняется один из props — срабатывает запрос сервера, мы это отрисовываем. И здесь может получиться так, что изменился 1 props, а сработали сразу все 3. Возникает лишняя нагрузка на сервер, могут появиться баги, потому что данные придут не в той последовательности. Чтобы хук срабатывал только для конкретного props, надо сделать 3 разных useEffect.
Одна из интересных особенностей useEffect — если вторым аргументом передать пустой массив, эффект сработает всего раз: при монтировании и размонтировании. На практике, если я проверяю, у меня вышел не 1 рендер, а 2. Смотрим в changelog React и видим «Stricter strict mode», строгий режим стал строже. С марта 2022 года React стал автоматически размонтировать и обратно монтировать каждый компонент при первом рендере.
Чтобы это исправить, можно просто отключить strict mode, но я не советую так делать, особенно если ваш проект будет развиваться еще несколько лет, и вам, возможно, придется обновлять версию React. Strict mode нужен, чтобы мы могли увидеть узкие места в приложении, которые в текущей версии React не вызывают багов, но могут вызвать в следующей. Strict mode заранее предупреждает, что это нужно исправить. Если его отключить, потом с большой долей вероятности вам на голову свалится куча неожиданных багов, и вы не сможете просто и легко обновиться до более новой версии React.
Выход простой — использовать useRef и записывать, что при первом рендере заходим в условия, которые находятся в useEffect. Мы выполняем нашу логику функции и записываем: isFirstRender = false. В итоге, при первоначальной реализации было 2 рендера, а сейчас 1.
Последний пример с useEffect — когда нужно подписаться на какое-то событие с помощью этого хука. Например, на получение каких-то данных с удаленного сервера или на клик пользователя. И здесь вылезает баг: я кликаю 1 раз, но у нас показывает, будто совершено 2 клика.
Самое простое решение — записать функцию в переменную. Затем при монтировании мы подписываемся на какое-то событие, а при размонтировании — отписываемся. Тогда все будет отлично работать. Если этот способ использовать не выходит, стоит перенести эту логику в какой-то state manager: Redux или MobX.
Нюансы работы с useState
Представим, что есть пользовательский компонент, с которого надо собрать статистику: сколько кликов человек на нем делает. Внутри есть 2 функции: первая — какой-то счетчик, вторая — при выходе пользователь отправляет статистику нам на сервер. В целом, это работает, но при каждом изменении useState у нас будет происходить ре-рендер. React оптимизирован для ре-рендеров, но если компонент сложнее, чем из двух div, это будет плохо сказываться на перфомансе.
Чтобы избежать этого, возьмем useRef вместо useState. В классовых компонентах его аналогом будет createRef, но в функциональных useRef полезнее и чаще применяется. В нем можно хранить state, в том числе при перерисовках, и он не вызывает ре-рендер. Так что если текущее состояние не используется где-то для отображения пользователю, лучше использовать useRef. Но если нужно обновленное состояние в разметке — берем useState.
И напоследок приведу кейс, который нередко встречается на собеседованиях у джунов, пре-миддлов и даже миддлов. Если мы в одной функции будем несколько раз обновлять state и брать текущее значение из вызова хука, то очень возможны какие-то баги — state не всегда синхронно обновляется. Лучше исключить такие риски и текущее значение брать не из useState count, а из аргумента — так у него всегда будет актуальное состояние.
Хочешь развивать школьный EdTech вместе с нами — присоединяйся к команде Учи.ру!