Хуки в React — как надо, когда не надо и нужны ли свои?
В 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 вместе с нами — присоединяйся к команде Учи.ру!