Повторное использование логики с помощью пользовательских хуков
React предоставляет встроенные хуки, такие как useState
, useContext
, и useEffect
. Иногда могут понадобиться хуки, решающие более частные задачи: получать данные, отслеживать, подключён ли пользователь к сети, или устанавливать соединение с чатом. Встроенных решений для этого в React нет, но вы всегда можете написать собственные хуки под задачи вашего приложения.
Вы узнаете
- Что такое пользовательские хуки, и как их создавать
- Как повторно использовать логику между компонентами
- Как выбрать имя и структуру пользовательских хуков
- Когда и зачем выносить логику в пользовательские хуки
Пользовательские хуки: Повторное использование логики между компонентами
Представьте, что вы разрабатываете приложение, тесно связанное с использованием сети (как большинство приложений). Вы хотите предупредить пользователя, что сетевое соединение было случайно разорвано во время использования приложения. Как вы это сделаете? Скорее всего, вам нужны две вещи в компоненте:
- Состояние, обозначающее, подключен ли пользователь к сети.
- Эффект, который подписывается на глобальные события
online
иoffline
, и обновляет это состояние.
Так компонент будет синхронизирован с текущим состоянием сети. Вы можете начать с чего-то подобного:
import { useState, useEffect } from 'react'; export default function StatusBar() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return <h1>{isOnline ? '✅ В сети' : '❌ Не в сети'}</h1>; }
Попробуйте отключить сеть, и вы увидите, как StatusBar
обновляется в ответ.
Теперь представьте, что вы хотите использовать эту логику ещё в одном компоненте. Вы хотите добавить кнопку “Сохранить”, которая станет неактивной при отсутствии подключения к сети и будет отображать надпись “Подключение…” вместо “Сохранить”.
Мы можем просто скопировать состояние isOnline
и эффект в SaveButton
:
import { useState, useEffect } from 'react'; export default function SaveButton() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); function handleSaveClick() { console.log('✅ Сохранено!'); } return ( <button disabled={!isOnline} onClick={handleSaveClick}> {isOnline ? 'Сохранить' : 'Подключение...'} </button> ); }
Попробуйте отключить сеть — кнопка должна изменить своё состояние.
Оба компонента работают корректно, но повторение одного и того же кода в нескольких местах — не лучшая практика. Даже если компоненты выглядят по-разному, общая логика может быть вынесена в одно место.
Извлекаем пользовательский хук из компонента
Представьте, что в React, кроме useState
и useEffect
, есть встроенный хук useOnlineStatus
. Тогда мы могли бы использовать его в обоих компонентах и убрать повторяющийся код:
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ В сети' : '❌ Не в сети'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Сохранено!');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Сохранить' : 'Подключение...'}
</button>
);
}
Пусть такого хука и нет в React, ничто не мешает нам написать его самостоятельно. Создайте функцию useOnlineStatus
и перенесите туда повторяющийся код из компонентов:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
В конце функции верните isOnline
— это значение будет доступно компонентам.
import { useOnlineStatus } from './useOnlineStatus.js'; function StatusBar() { const isOnline = useOnlineStatus(); return <h1>{isOnline ? '✅ В сети' : '❌ Не в сети'}</h1>; } function SaveButton() { const isOnline = useOnlineStatus(); function handleSaveClick() { console.log('✅ Сохранено!'); } return ( <button disabled={!isOnline} onClick={handleSaveClick}> {isOnline ? 'Сохранить' : 'Подключение...'} </button> ); } export default function App() { return ( <> <SaveButton /> <StatusBar /> </> ); }
Проверьте, что при отключении сети обновляются оба компонента.
Отлично! Теперь в компонентах стало меньше дублирующегося кода. Более того, теперь их код описывает что они делают (используют состояние сети!), а не как именно они это делают (подпиской на события браузера).
При извлечении логики в пользовательские хуки, вы прячете ненужные для компонента глубокие детали взаимодействия с браузерными API или внешними сервисами. Код компонента отражает само намерение, а не способ его реализации.
Имя хуков должны начинаться с use
Приложения React состоят из компонентов. Компоненты создаются с помощью хуков, встроенных или пользовательских. Наверное, вы часто используете хуки, написанные другими, но, возможно, вы захотите написать собственный?
Для этого нужно соблюдать соглашения имён:
- Имя React компонента должно начинаться с заглавной буквы, вроде
StatusBar
илиSaveButton
. React компонент должен вернуть что либо, что React сможет отобразить, например JSX элемент. - Имя хуков должно начинаться с
use
после которго следует заглавная буква, вродеuseState
(встроенный хук) илиuseOnlineStatus
(пользовательский, как мы делали выше). Хуки могут возвращать любые значения.
Это соглашение помогает сразу увидеть, где в компоненте используются состояние, эффекты и другие функции React. Когда вы видите функциюgetColor()
в вашем компоненте, вы можете быть уверены, что она не использует функции React, вроде useState
, так как имя функции не начинается с use
. А вот вызов функции useOnlineStatus()
скорее всего будет использовать хуки или другие функции React.
Deep Dive
Нет. Функции, которые не используют хуки не должны быть хуками.
Если ваша функция не использует хуки, не используйте приставку use
. Сделайте её обычной функцией без приставки use
. Например, функция useSorted
в примере ниже не использует хуки, так что её стоит переменовать в getSorted
:
// 🔴 Нельзя: Хук, не использующий другие хуки
function useSorted(items) {
return items.slice().sort();
}
// ✅ Отлично: Обычная функция, не использующая хуки
function getSorted(items) {
return items.slice().sort();
}
Это гарантирует, что React сможет вызывать её, в том числе внутри if:
function List({ items, shouldSort }) {
let displayedItems = items;
if (shouldSort) {
// ✅ Мы можем вызвать getSorted внутри if, потому что это обычная функция.
displayedItems = getSorted(items);
}
// ...
}
Нужно добавлять приставку use
к функции (тем самым превращая её в хук) если она содержит в себе хотя бы один хук:
// ✅ Хорошо: Хук, использующий другие хуки
function useAuth() {
return useContext(Auth);
}
Вы даже можете сделать хук, который не использует другие хуки. Технически, React не ограничивает вас в этом. Но такой подход сбивает с толку, поэтому советуем избегать его, кроме редких случаев, где это может принести пользу. Например, если прямо сейчас ваша функция не использует хуки, но в будущем вы собираетесь добавить их. Тогда есть смысл использовать приставку use
:
// ✅ Хорошо: Хук, не использующий другие хуки, но собирающийся использовать их в будущем
function useAuth() {
// TODO: заменить строку ниже после реализации аутентификации
// return useContext(Auth);
return TEST_USER;
}
В таком случае компоненты не смогут вызывать его внутри блока if
. Это станет важным, когда вы добавите вызовы хуков. Если вы не планируете добавлять вызовы хуков — не делайте вашу функцию хуком.
Пользовательские хуки позволяют повторно использовать логику состояния, но не само состояние
Ранее, когда мы меняли состояние сети, оба компонента обновлялись одновременно. Но неправильно думать, что между ними используется одна и та же переменная состояния isOnline. Обратите внимание на код ниже:
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
Это работает как и раньше, до вынесения повторяющегося кода
function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
function SaveButton() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
Это две совершенно независимые переменные состояния и эффекта! И они имеют одинаковое значение, только потому, что мы привязали их к внешнему значению (есть ли подключение к интернету).
Чтобы лучше понять, нам нужен другой пример. Рассмотрим компонент Form
:
import { useState } from 'react'; export default function Form() { const [firstName, setFirstName] = useState('Владимир'); const [lastName, setLastName] = useState('Маяковский'); function handleFirstNameChange(e) { setFirstName(e.target.value); } function handleLastNameChange(e) { setLastName(e.target.value); } return ( <> <label> Имя: <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Фамилия: <input value={lastName} onChange={handleLastNameChange} /> </label> <p><b>Доброе утро, {firstName} {lastName}.</b></p> </> ); }
Здесь мы имеем повторяющуюся логику для каждого поля ввода:
- Часть состояния (
firstName
andlastName
). - Функции для изменения состояния (
handleFirstNameChange
andhandleLastNameChange
). - А также JSX, определяющий атрибуты
value
иonChange
.
Можно извлечь повторяющуюся логику в пользовательский хук useFormInput
:
import { useState } from 'react'; export function useFormInput(initialValue) { const [value, setValue] = useState(initialValue); function handleChange(e) { setValue(e.target.value); } const inputProps = { value: value, onChange: handleChange }; return inputProps; }
Обратите внимание: переменная состояния value
здесь только одна.
Однако, компонент Form
вызывает useFormInput
дважды
function Form() {
const firstNameProps = useFormInput('Владимир');
const lastNameProps = useFormInput('Маяковский');
// ...
Вот почему это работает как объявление двух отдельных переменных состояния!
Пользовательские хуки позволяют поделиться логикой состояния но не самим состоянием. Каждый вызов хука абсолютно независим от других вызовов этого же хука. Вот почему примеры выше полностью одинаково работают. Если хотите, можете подняться выше, и проверить это. Поведение и результат до и после извлечения пользовательского хука одинаковы.
Если вы хотите поделиться именно переменной состояния между компонентами, используйте поднятие состояния и её передачу вниз.
Передача реактивных значений между хуками
Код пользовательского хука выполняется заново при каждом рендере компонента. Поэтому, как и компоненты, ваши хуки должны быть чистыми функциями.. Лучше воспринимать их как часть компонента, а не как отдельные функции.
Поскольку хуки повторно рендерятся вместе с компонентом, они всегда получают актуальные пропсы и состояние. Чтобы убедиться в этом, посмотрите на пример чат-комнаты ниже. Измените URL сервера или комнату в чате:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; import { showNotification } from './notifications.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useEffect(() => { const options = { serverUrl: serverUrl, roomId: roomId }; const connection = createConnection(options); connection.on('message', (msg) => { showNotification('Новое сообщение: ' + msg); }); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]); return ( <> <label> URL сервера: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Добро пожаловать в комнату {roomId}!</h1> </> ); }
Когда вы изменяете serverUrl
или roomId
, эффект “реагирует” на эти изменения и заново синхронизируется. Вы можете выводить в консоль сообщения о повторном присоединении к чату при изменении зависимостей вашего эффекта.
Вынесем код эффекта в пользовательский хук:
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('Новое сообщение: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
Это позволяет компоненту ChatRoom
использовать хук, не вникая в его внутреннюю реализацию:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
return (
<>
<label>
URL сервера:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Добро пожаловать в комнату {roomId}!</h1>
</>
);
}
Теперь это выглядит куда проще! (Пускай и работает точно так же.)
Заметьте, что логика всё ещё реагирует на изменения пропсов и состояния. Попробуйте изменить URL сервера или выбранную комнату:
import { useState } from 'react'; import { useChatRoom } from './useChatRoom.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useChatRoom({ roomId: roomId, serverUrl: serverUrl }); return ( <> <label> URL сервера: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Добро пожаловать в комнату {roomId}!</h1> </> ); }
Обратите внимание, как мы берём возвращаемое значение из одного хука:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
И передаём их как параметры другому хуку:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
При повторном рендере компонента ChatRoom
, он передаёт актуальные roomId
и serverUrl
вашему хуку. Вот почему эффект снова подключается к чату даже если значения меняются после повторного рендера. (Если вы когда-либо работали с программами для обработки видео или звука, цепочки хуков вроде этой могут напомнить вам цепочки аудио или визуальных эффектов. Это похоже на то, что выходные параметры useState
“скармливаются” на вход useChatRoom
.)
Передача слушателей событий пользовательским хукам
Когда вы начнёте использовать useChatRoom
в большем количестве компонентов, вы, возможно, захотите изменять их поведение. Например, сейчас логика того, что происходит после получения нового сообщения, зафиксирована в коде внутри хука:
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('Новое сообщение: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
Допустим, вы хотите перенести эту логику обратно в компонент:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl,
onReceiveMessage(msg) {
showNotification('Новое сообщение:' + msg);
}
});
// ...
Чтобы это работало, измените добавьте onReceiveMessage
во входные параметры:
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onReceiveMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl, onReceiveMessage]); // ✅ Все зависимости указаны
}
Это будет работать, однако есть ещё одно улучшение, которое можно сделать для того, чтобы ваш хук принимал на вход обработчики событий.
Если добавить onReceiveMessage в зависимости, хук будет переподключать чат при каждом рендере — это неэффективно. Оберните этот обработчик события в События эффектов, чтобы убрать его из списка зависимостей:
import { useEffect, useEffectEvent } from 'react';
// ...
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ Все зависимости указаны
}
Теперь подключение к чату не будет происходить заново при каждом рендере ChatRoom. Ниже представлен полностью рабочий пример передачи обработчика события пользовательскому хуку. Попробуйте его!
import { useState } from 'react'; import { useChatRoom } from './useChatRoom.js'; import { showNotification } from './notifications.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useChatRoom({ roomId: roomId, serverUrl: serverUrl, onReceiveMessage(msg) { showNotification('Новое сообщение: ' + msg); } }); return ( <> <label> URL сервера: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Добро пожаловать в комнату {roomId}!</h1> </> ); }
Обратите внимание: теперь вам не нужно знать, как работает useChatRoom
, чтобы его использовать. Его можно использовать в любом компоненте: передайте параметры — и он будет вести себя так же, как раньше. В этом и заключается сила пользовательских хуков.
Когда использовать пользовательские хуки
Не стоит создавать пользовательские хуки для каждого небольшого кусочка повторяющейся логики. Небольшое повторение кода — не проблема. Например, вынос хука useFormInput
для создания обёртки над одним вызовом useState
, как в примере выше, может быть излишним.
Везде и всегда, создавая эффекты, создавайте их более чистыми для дальнейшего оборачивания в пользовательские хуки. Не используйте эффекты слишком часто, но если вы создаёте эффект, это значит, что вам нужно “выйти за пределы React” чтобы синхронизироваться с какой-либо сторонней системой или сделать что-то, что React не пользоваляет сделать с помощью встроенных API. Оборачивание в пользовательский хук помогает точнее выразить намерения компонента и связь данных с этими намерениями.
Для примера возьмём компонент ShippingForm
, который использует два выпадающих списка: в одном список городов, в другом список районов внутри города. Для начала хватит кода ниже:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
// Эффект, запрашивающий список городов
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]);
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
// Эффект, запрашивающий список районов в городе
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]);
// ...
Пускай здесь есть небольшое повторение кода, правильно разделять эффекты друг от друга. Они синхронизированы с двумя разными сущностями, так что не стоит объединять их в один эффект. Вместо этого, можно упростить ShippingForm
, вынеся общую логику в пользовательский хук useData
:
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
if (url) {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}
}, [url]);
return data;
}
Теперь оба эффекта в ShippingForm
можно заменить вызовами хука useData
:
function ShippingForm({ country }) {
const cities = useData(`/api/cities?country=${country}`);
const [city, setCity] = useState(null);
const areas = useData(city ? `/api/areas?city=${city}` : null);
// ...
Вынос пользовательской логики в хук делает поток данных более явным. Вы передаёте url
— и получаете data
. “Скрывая” эффект внутри хука useData
, вы также защищаете компонент ShippingForm
от добавления ненужных зависимостей внутрь него. Чем дольше развивается проект, тем больше эффектов вашего приложения будут вынесены в пользовательские хуки.
Deep Dive
Начните с выбора названия вашего хука. Если вам сложно выбрать подходящее имя, это может сигнализировать о том, что ваш эффект слишком связан с остальной логикой компонента и пока не готов быть вынесенным.
В идеале, имя вашего хука должно быть понятно даже человеку с небольшим опытом программирования, что он принимает, что он делает, и что возвращает:
- ✅
useData(url)
- ✅
useImpressionLog(eventName, extraData)
- ✅
useChatRoom(options)
Если вы используете стороннюю систему, вы можете использовать технический жаргон, применимый к этой системе. Это сразу даст понять о чём идёт речь тем, кто знаком с этой системой:
- ✅
useMediaQuery(query)
- ✅
useSocket(url)
- ✅
useIntersectionObserver(ref, options)
Пользовательские хуки должны выполнять конкретные, высокоуровневые задачи Избегайте создания и использования пользовательских хуков “жизненного цикла”, которые пытаются переопределить логику работы API useEffect
:
- 🔴
useMount(fn)
- 🔴
useEffectOnce(fn)
- 🔴
useUpdateEffect(fn)
Например, хук useMount
ниже пытается вызвать код “только при монтировании”:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// 🔴 Избегайте: использования пользовательских хуков "жизненного цикла"
useMount(() => {
const connection = createConnection({ roomId, serverUrl });
connection.connect();
post('/analytics/event', { eventName: 'visit_chat' });
});
// ...
}
// 🔴 Избегайте: создания пользовательских хуков "жизненного цикла"
function useMount(fn) {
useEffect(() => {
fn();
}, []); // 🔴 React Hook useEffect has a missing dependency: 'fn'
}
Пользовательские хуки “жизненного цикла” вроде useMount
не встраиваются в парадигму React. Этот код содержит ошибку (не реагирует на изменения roomId
или serverUrl
), но линтер не среагирует на это, так как проверяет только прямые вызовы useEffect
. Он не будет знать про ваш хук.
Если вы пишете эффект — используйте React API напрямую:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ Хорошо: два разных эффекта, разделённых по назначению
useEffect(() => {
const connection = createConnection({ serverUrl, roomId });
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
useEffect(() => {
post('/analytics/event', { eventName: 'visit_chat', roomId });
}, [roomId]);
// ...
}
Далее, вы можете (но не должны) извлечь два пользовательских хука для высокоуровневого взаимодействия:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ Супер: названия пользовательских хуков отражают их назначение
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
// ...
}
Хорошие пользовательские хуки вызывают код более декларативно и сковывают его в действиях. К примеру, useChatRoom(options)
сможет только присоединиться к чат-комнате useImpressionLog(eventName, extraData)
сможет только отправить логи. Если API вашего пользовательского хука слишком абстрактен и не ограничивает использование, он может создать больше проблем, чем решить.
Пользовательские хуки помогают улучшить подходы к написанию кода
Эффекты — это “лазейка”: вы используете их только для того, чтобы “выйти за пределы React” и когда нет более хорошего встроенного средства для решения вашей проблемы. В дальнейшем, целью команды React будет сокращение количества эффектов, которые вы будете использовать в приложении, путём предоставления более узконаправленных инструментов для решения более узконаправленных проблем. Оборачивание эффектов в пользовательские хуки поможет легче адаптироваться к будущим изменениям в React.
Давайте вернёмся к нашему примеру:
import { useState, useEffect } from 'react'; export function useOnlineStatus() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return isOnline; }
В примере выше, useOnlineStatus
был создан с помощью пары useState
и useEffect
. На деле это не самое надёжное решение: оно не учитывает некоторые пограничные случаи. Оно предполагает, что isOnline
уже true
, но это может не сработать, если пользователь изначально не в сети. Можно использовать браузерное API navigator.onLine
для проверки, но прямое использование не позволит серверу отрендерить начальную HTML-разметку. Вкратце, мы можем улучшить этот код.
Как раз для нашего случая, в React 18 был представлен хук useSyncExternalStore
который берёт на себя ответственность за решение этих проблем за вас. Вот как хук useOnlineStatus
, может быть переписан, используя новый API:
import { useSyncExternalStore } from 'react'; function subscribe(callback) { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; } export function useOnlineStatus() { return useSyncExternalStore( subscribe, () => navigator.onLine, // Как получить значение на клиенте () => true // Как получить значение на сервере ); }
Заметьте, как вам не нужно менять свой компонент чтобы мигрировать на новое API:
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
Есть дополнительные выигрыши при оборачивании эффектов в пользовательские хуки:
- Вы делаете поток данных от и к вашему компоненту наиболее явным.
- Центром логики вашего компонента становится его намерение, и они не перегружаются логикой имплементации эффектов.
- При добавлении новых функций в React, вам не придётся изменять код компонентов.
Также, как и в дизайн системах, может быть полезно начать извлекать часто встречающиеся идиомы в пользовательские хуки. Это позволит компонентам сфокусироваться на намерениях, и позволит реже использовать эффекты. Множество замечательных пользовательских хуков создаются и поддерживаются сообществом React.
Deep Dive
Мы продолжаем рассуждать над деталями, и мы ожидаем, что в будущем вы будете загружать данные примерно так:
import { use } from 'react'; // Ещё не доступно!
function ShippingForm({ country }) {
const cities = use(fetch(`/api/cities?country=${country}`));
const [city, setCity] = useState(null);
const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null;
// ...
Если ваши пользовательские хуки похожи на useData
, в будущем они потребуют небольших изменений для миграции на окончательно одобренный подход загрузки данных вместо создания эффектов в каждом компоненте вручную. Классический подход с эффектами по-прежнему актуален, и вы можете спокойно продолжать его использовать.
Есть больше одного способа реализации
Допустим, вы хотите создать анимацию появления с помощью браузерного API requestAnimationFrame
. Можно начать с эффекта, создающего зацикленную анимацию. Во время каждого кадра, вы можете изменять прозрачность DOM-элемента, который передан в ref, пока прозрачность не станет равна 1
. Начнём:
import { useState, useEffect, useRef } from 'react'; function Welcome() { const ref = useRef(null); useEffect(() => { const duration = 1000; const node = ref.current; let startTime = performance.now(); let frameId = null; function onFrame(now) { const timePassed = now - startTime; const progress = Math.min(timePassed / duration, 1); onProgress(progress); if (progress < 1) { // Запрашиваем новый кадр frameId = requestAnimationFrame(onFrame); } } function onProgress(progress) { node.style.opacity = progress; } function start() { onProgress(0); startTime = performance.now(); frameId = requestAnimationFrame(onFrame); } function stop() { cancelAnimationFrame(frameId); startTime = null; frameId = null; } start(); return () => stop(); }, []); return ( <h1 className="welcome" ref={ref}> Welcome </h1> ); } export default function App() { const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow(!show)}> {show ? 'Удалить' : 'Показать'} </button> <hr /> {show && <Welcome />} </> ); }
Чтобы сделать код более читаемым, можно извлечь логику в пользовательский хук useFadeIn
:
import { useState, useEffect, useRef } from 'react'; import { useFadeIn } from './useFadeIn.js'; function Welcome() { const ref = useRef(null); useFadeIn(ref, 1000); return ( <h1 className="welcome" ref={ref}> Привет! </h1> ); } export default function App() { const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow(!show)}> {show ? 'Удалить' : 'Показать'} </button> <hr /> {show && <Welcome />} </> ); }
Можно оставить код useFadeIn
как есть, но вы можете ещё доработать его. Можно вынести код инициализации анимации из useFadeIn
в другой пользовательский хук useAnimationLoop
:
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; export function useFadeIn(ref, duration) { const [isRunning, setIsRunning] = useState(true); useAnimationLoop(isRunning, (timePassed) => { const progress = Math.min(timePassed / duration, 1); ref.current.style.opacity = progress; if (progress === 1) { setIsRunning(false); } }); } function useAnimationLoop(isRunning, drawFrame) { const onFrame = useEffectEvent(drawFrame); useEffect(() => { if (!isRunning) { return; } const startTime = performance.now(); let frameId = null; function tick(now) { const timePassed = now - startTime; onFrame(timePassed); frameId = requestAnimationFrame(tick); } tick(); return () => cancelAnimationFrame(frameId); }, [isRunning]); }
Однако совсем не обязательно было делать именно так. Как и в случае с обычными функциями, только вам решать, где проводить границы между частями вашего кода. Можно было бы подойти к задаче совершенно иначе: вместо того чтобы оставлять логику внутри эффекта, вы могли бы перенести основную императивную логику в классы:
import { useState, useEffect } from 'react'; import { FadeInAnimation } from './animation.js'; export function useFadeIn(ref, duration) { useEffect(() => { const animation = new FadeInAnimation(ref.current); animation.start(duration); return () => { animation.stop(); }; }, [ref, duration]); }
Эффекты позволяют связывать React с внешними системами. Чем больше необходимо связи между эффектами (может, чтобы делать цепочку анимаций), тем больше смысла полностью вынести логику в пользовательские хуки, как в примерах выше. После этого вынесенный код становится “внешней системой”. Это позволяет эффектам оставаться простыми и читаемыми, поскольку теперь они занимаются только общением с той самой “внешней системой”.
Пример выше предполагает, что анимация будет написана на Javascript. Однако простые анимации вроде появления или исчезания легче делать с помощью CSS анимаций:
.welcome { color: white; padding: 50px; text-align: center; font-size: 50px; background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%); animation: fadeIn 1000ms; } @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
Иногда хуки вообще не нужны!
Recap
- Пользовательские хуки позволяют делиться логикой между компонентами.
- Имена пользовательских хуков должны начинаться с
use
, после которого следует заглавная буква. - Пользовательские хуки позволяют делиться логикой состояния, но не самим состоянием.
- Вы можете передавать реактивные значения между хуками, и эти значения будут оставаться актуальными.
- Все хуки выполняются заново при каждом рендере компонента, использующего их.
- Код внутри хуков должен быть чистым, как и код компонентов.
- Обработчики событий, полученные от пользовательских хуков, нужно оборачивать в События эффектов.
- Не создавайте и не используйте хуки вроде
useMount
. Ваши хуки должны выполнять конкретные задачи. - Где провести границы между частями кода — решаете вы.
Challenge 1 of 5: Вынесите логику в хук useCounter
Компонент использует состояние и эффект, чтобы отображать счётчик, увеличивающийся каждую секунду.
Вынесите эту логику в хук useCounter
. Ваша задача — чтобы компонент Counter
выглядел именно так:
export default function Counter() {
const count = useCounter();
return <h1>Секунд прошло: {count}</h1>;
}
Вынесите логику в файл useCounter.js
и подключите его в App.js
.
import { useState, useEffect } from 'react'; export default function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>Секунд прошло: {count}</h1>; }