
В 16.8 версии библиотеки React впервые появились хуки (hooks) — функции, которые упрощают работу с компонентами React и переиспользованием какой-либо логики. В экосистеме React уже есть много дефолтных хуков, но также можно создавать и свои. Я Михаил Карямин, фронтенд-разработчик в Учи.ру, расскажу, как и в каких случаях хуки в React облегчают жизнь разработчику и как с ними работать.
React без хуков и с ними
Чтобы понять, почему хуки упрощают жизнь разработчику, надо посмотреть на то, как писался React раньше. Он был на классовых компонентах: есть стандартный метод рендер, который отвечает за разметку, есть поле state, где хранится объект и все его состояния, есть какие-то свои методы и есть методы жизненных циклов. Всё это выглядит очень громоздко, и практически не актуально.
Классовый компонент
class Welcome extends React.Component { state = { money: 0 }; increaseMoney() { this.setState((prevState) => ({ money: prevState.money++ })); } render() { return ( <div> Привет, {this.props.name} у тебя {this.state.money} <button onClick={this.increaseMoney}>Добавить денег</button> </div> ); } }
Многие сегодняшние проекты React пишутся уже на функциональных компонентах. Есть функция, которая возвращает разметку, и внутри функции есть хуки для хранения состояния (state) или хуки для логики.
Функциональный компонент — new
function Welcome(props) { const [money, increaseMoney] = React.useState(100); const onClick = () => { increaseMoney((prevMoney) => prevMoney++); }; return ( <div> Привет, {props.name} у тебя {money} <button onClick={onClick}>Добавить денег</button> </div> ); }
Важно: несмотря на то что React в классовом виде встречается редко, почитать, как это работает, будет все же не лишним. Возможно, вам придется поддерживать написанный таким способом проект.
До хуков в классовых компонентах для хранения общей переиспользуемой логики самыми распространенными вариантами были так называемые higher-order component (HOC). Это функция, которая оборачивает обычные классовые компоненты. В качестве аргумента она принимает компонент, к которому нужна какая-то переиспользуемая логика. HOC тяжело читаются, во время учебы я долго не мог понять, как тут всё взаимосвязано и куда что передается.
Higher-Order Component
const withFetch = (WrappedComponent) => { class WithFetch extends React.Component { constructor(props) { super(props); this.state = { movies: [] }; } componentDidMount() { fetch("http://json-faker.onrender.com/movies") .then((response) => response.json()) .then((data) => { this.setState({ movies: data.movies }); }); } render() { return ( <> {this.state.movies.length > 0 && ( <WrappedComponent movies={this.state.movies} /> )} </> ); } } WithFetch.displayName = `WithFetch(${WithFetch.name})`; return WithFetch; };
Компонент с Higher-Order Component
import MovieContainer from "../component/MovieContainer"; import withFetch from "./MovieWrapper"; class MovieListWithHOC extends React.Component { constructor(props) { super(props); } render() { return ( <div> <h2>Movie list - with HOC</h2> <MovieContainer data={this.props.movies} /> </div> ); } } export default withFetch(MovieListWithHOC);
Сегодня вместо HOC используются хуки. Компонент с хуком смотрится намного компактнее и понятнее: вся логика занимает одну строчку «const [loading, data] = useFetch(MOVIE_URI)» — хук возвращает текущее состояние и данные. А если нужны несколько переиспользуемых бизнес-логик, можно просто добавить еще одну строчку и появится дополнительный компонент. В случае с HOC пришлось бы оборачивать компоненты: это не очень красиво и тяжело читается.
Hook
const useFetch = (url) => { const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(true); useEffect(() => { const fetchData = async () => { const response = await fetch(url); const data = await response.json(); setData(data); setLoading(false); }; fetchData(); }, [url]); return [loading, data]; };
Компонент с хуком
import { useFetch } from "../hooks/useFetch"; import Movie from "../components/Movie"; const MovieWithHook = () => { const MOVIE_URI = "http://json-faker.onrender.com/movies"; const [loading, data] = useFetch(MOVIE_URI); return ( <div> <h2>Moview with hook</h2> {loading ? <h3>loading...</h3> : <Movie data={data.movies} />} </div> ); };
Как пишется собственный хук
Первая итерация
Предлагаю попробовать написать хук и посмотреть, как работает концепт 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 и изменять его.
const increaseState = (() => { let someState = 0; return () => { someState = someState + 1; return someState; } })() console.log(increaseState()) console.log(increaseState()) console.log(increaseState()) console.log(increaseState()) console.log(increaseState())
Вторая итерация
Теперь надо написать функцию 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.
const useState = (intialValue) => { let value = intialValue; const getState = () => value; const setState = (newValue) => { value = newValue } return [getState, setState]; } const [getCount, setCount] = useState(0) console.log(getCount()) setCount(2) console.log(getCount())
Третья итерация
Заведем немедленно вызываемую функцию (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, а будем использовать именно внутренний, так как он будет верный.
Теперь в консоли все работает, и у нас есть простой функциональный хук.
const React = (() => { const states = []; let idx = 0; const useState = (intialValue) => { const state = states[idx] || intialValue; const _idx = idx; const setState = (newValue) => { states[_idx] = newValue; }; idx++; return [state, setState]; }; const render = (Component) => { idx = 0; const component = Component(); component.render(); return component; }; return { useState, render }; })(); const Component = () => { const [name, setName] = React.useState("Mike"); const [surname, setSurname] = React.useState("Petrov"); return { render: () => console.log(`${name} ${surname}`), changeName: (newName) => setName(newName), changeSurname: (newSurname) => setSurname(newSurname) }; }; let App = React.render(Component); App = React.render(Component); App.changeName("Petya"); App = React.render(Component);
Кастомные хуки и их применение
Все кастомные хуки состоят из дефолтных: 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.
function Example({ currentType, name, surname, number }) { const [infoByName, setInfoByName] = React.useState(); const [infoBySurname, setInfoBySurname] = React.useState(); const [infoByNumber, setInfoByNumber] = React.useState(); React.useEffect(() => { const fetchByName = async () => { const response = await fetch("URI"); const data = await response.json(); setInfoByName(data); }; fetchByName(); }, [name]); React.useEffect(() => { const fetchBySurname = async () => { const response = await fetch("URI"); const data = await response.json(); setInfoBySurname(data); }; fetchBySurname(); }, [surname]); React.useEffect(() => { const fetchByNumber = async () => { const response = await fetch("URI"); const data = await response.json(); setInfoByNumber(data); }; fetchByNumber(); }, [number]); return ( <div> {currentType === "name" && <div>{infoByName}</div>} {currentType === "surname" && <div>{infoBySurname}</div>} {currentType === "number" && <div>{infoByNumber}</div>} </div> ); }
Одна из интересных особенностей useEffect — если вторым аргументом передать пустой массив, эффект сработает всего раз: при монтировании и размонтировании. На практике, если я проверяю, у меня вышел не 1 рендер, а 2. Смотрим в changelog React и видим «Stricter strict mode», строгий режим стал строже. С марта 2022 года React стал автоматически размонтировать и обратно монтировать каждый компонент при первом рендере.
const BadUseEffectOnce = () => { const [count, setCount] = useState(0); React.useEffect(() => { setCount((prevCount) => prevCount + 1); }, []); return <div>{count}</div>; }; export default function App() { return ( <div className="app"> <React.StrictMode> <h1> Количество ререндеров: <BadUseEffectOnce /> </h1> </React.StrictMode> </div> ); }
Чтобы это исправить, можно просто отключить strict mode, но я не советую так делать, особенно если ваш проект будет развиваться еще несколько лет, и вам, возможно, придется обновлять версию React. Strict mode нужен, чтобы мы могли увидеть узкие места в приложении, которые в текущей версии React не вызывают багов, но могут вызвать в следующей. Strict mode заранее предупреждает, что это нужно исправить. Если его отключить, потом с большой долей вероятности вам на голову свалится куча неожиданных багов, и вы не сможете просто и легко обновиться до более новой версии React.
const BadUseEffectOnce = () => { const [count, setCount] = useState(0); React.useEffect(() => { setCount((prevCount) => prevCount + 1); }, []); return <div>{count}</div>; }; export default function App() { return ( <div className="app"> <h1> Количество ререндеров: <BadUseEffectOnce /> </h1> </div> ); }
Выход простой — использовать useRef и записывать, что при первом рендере заходим в условия, которые находятся в useEffect. Мы выполняем нашу логику функции и записываем: isFirstRender = false. В итоге, при первоначальной реализации было 2 рендера, а сейчас 1.
const GoodUseEffectOnce = () => { const [count, setCount] = useState(0); const isFirstRender = React.useRef(true); React.useEffect(() => { if (isFirstRender.current) { setCount((prevCount) => prevCount + 1); isFirstRender.current = false; } }, []); return <div>{count}</div>; }; export default function App() { return ( <div className="app"> <React.StrictMode> <h1> Количество ререндеров (Bad): <BadUseEffectOnce /> Количество ререндеров (Good): <GoodUseEffectOnce /> </h1> </React.StrictMode> </div> ); }
Последний пример с useEffect — когда нужно подписаться на какое-то событие с помощью этого хука. Например, на получение каких-то данных с удаленного сервера или на клик пользователя. И здесь вылезает баг: я кликаю 1 раз, но у нас показывает, будто совершено 2 клика.
const BadUseEffectOnce = () => { const [count, setCount] = useState(0); React.useEffect(() => { document.addEventListener("click", () => { setCount((prevCount) => prevCount + 1); }); }, []); return <div>{count}</div>; }; export default function App() { return ( <div className="app"> <React.StrictMode> <h1> Количество ререндеров (Bad): <BadUseEffectOnce /> </h1> </React.StrictMode> </div> ); }
Самое простое решение — записать функцию в переменную. Затем при монтировании мы подписываемся на какое-то событие, а при размонтировании — отписываемся. Тогда все будет отлично работать. Если этот способ использовать не выходит, стоит перенести эту логику в какой-то state manager: Redux или MobX.
const GoodUseEffectOnce = () => { const [count, setCount] = useState(0); React.useEffect(() => { const listener = () => { setCount((prevCount) => prevCount + 1); }; document.addEventListener("click", listener); return () => { document.removeEventListener("click", listener); }; }, []); return <div>{count}</div>; }; export default function App() { return ( <div className="app"> <React.StrictMode> <h1> Количество ререндеров (Good): <GoodUseEffectOnce /> </h1> </React.StrictMode> </div> ); }
Нюансы работы с useState
Представим, что есть пользовательский компонент, с которого надо собрать статистику: сколько кликов человек на нем делает. Внутри есть 2 функции: первая — какой-то счетчик, вторая — при выходе пользователь отправляет статистику нам на сервер. В целом, это работает, но при каждом изменении useState у нас будет происходить ре-рендер. React оптимизирован для ре-рендеров, но если компонент сложнее, чем из двух div, это будет плохо сказываться на перфомансе.
Чтобы избежать этого, возьмем useRef вместо useState. В классовых компонентах его аналогом будет createRef, но в функциональных useRef полезнее и чаще применяется. В нем можно хранить state, в том числе при перерисовках, и он не вызывает ре-рендер. Так что если текущее состояние не используется где-то для отображения пользователю, лучше использовать useRef. Но если нужно обновленное состояние в разметке — берем useState.
И напоследок приведу кейс, который нередко встречается на собеседованиях у джунов, пре-миддлов и даже миддлов. Если мы в одной функции будем несколько раз обновлять state и брать текущее значение из вызова хука, то очень возможны какие-то баги — state не всегда синхронно обновляется. Лучше исключить такие риски и текущее значение брать не из useState count, а из аргумента — так у него всегда будет актуальное состояние.
Хочешь развивать школьный EdTech вместе с нами — присоединяйся к команде Учи.ру!
