Привет, друзья!
Представляю вашему вниманию перевод этой замечательной статьи, в которой рассказывается о разработке приложения с помощью React Query.
Прим. пер.: автор рассказывает лишь о ключевых особенностях приложения, поэтому я рекомендую клонировать репозиторий, установить зависимости и запустить сервер для разработки, чтобы иметь возможность выполнять необходимые операции при чтении статьи. Обратите внимание: если у вас возникнут проблемы при запуске сервера для разработки с помощью команды npm start
, перенесите переменные, определенные в этой команде в файле package.json
, в файл .env
:
SKIP_PREFLIGHT_CHECK=true
TSC_COMPILE_ON_ERROR=true
ESLINT_NO_DEV_ERRORS=true
И отредактируйте команду start
следующим образом:
"start": "react-scripts start"
Если вам когда-либо приходилось разрабатывать React-приложение
, в котором используются асинхронные данные (а это практически любое приложение), вы наверняка знаете, насколько уто мительным может быть обработка различных состояний (загрузка, ошибка и т.д.), распределение состояния между компонентами, обращающимися к одной и той же конечной точке API
и обеспечение согласованности состояния в компонентах.
Для обновления данных нам приходится выполнять множество операций: определять хуки useState
и useEffect
, получать данные из API
, помещать обновленные данные в состояние, менять состояние загрузки, обрабатывать ошибки и т.п. К счастью, у нас есть React Query
- библиотека, которая значительно облегчает получение, кэширование и управление данными.
Преимущества использования нового подхода
React Query
предоставляет впечатляющий перечень возможностей:
- кэширование;
- дедупликация одинаковых запросов;
- обновление устаревших данных в фоновом режиме (при установке фокуса, повторном подключении, периодически и др.);
- оптимизация производительности за счет пагинации и ленивой загрузки данных;
- мемоизация результатов запроса;
- предварительное получение данных;
- мутации, облегчающие реализацию оптимистичных обновлений etc.
Для демонстрации всех этих возможностей я разработал приложение, в котором реализована большая часть функционала, предоставляемого React Query
. Приложение написано на TypeScript
, в нем используется CRA, React Query
, Axios Mock Adapter и Material UI для быстрого прототипирования.
Функционал приложения
Приложение представляет собой реализацию системы обслуживания автомобилей. Функционал приложения следующий:
- авторизация пользователей с помощью адреса электронной почты и пароля;
- отображение списка предстоящих встреч (appointments - ТО) с возможностью загрузки дополнительных данных;
- отображение информации о конкретной встрече;
- сохранение и отображение истории изменений;
- предварительное получение дополнительной информации;
- добавление и изменение необходимых работ/задач.
Взаимодействие на стороне клиента
В качестве замены реального сервера в приложении используется axios-mock-adapter
. Я подготовил своего рода REST API
с конечными точками для GET/POST/PATCH/DELETE-запросов
. Для хранения данных используются фикстуры (fixtures). Ничего особенного - всего лишь мутируемые переменные.
Кроме того, для визуализации модификации состояния добавлена задержка в 1 секунду для ответа на каждый запрос.
Подготовка к использованию React Query
Для того, чтобы иметь возможность использовать фичи, предоставляемые React Query
, основной компонент приложения необходимо обернуть в соответствующий провайдер:
const queryClient = new QueryClient();
ReactDOM.render(
<React.StrictMode>
<Router>
<QueryClientProvider client={queryClient}>
<App />
<ToastContainer />
</QueryClientProvider>
</Router>
</React.StrictMode>,
document.getElementById('root')
);
Конструктор QueryClient
позволяет устанавливать некоторые глобальные настройки.
Для облегчения разработки мы создадим собственные абстракции для хуков React Query
. Для подписки на запрос (query) необходимо передать уникальный ключ. Простейшим способов является использование строк, но также можно использовать массивоподобные ключи.
В официальной документации используются строки, но я нахожу это немного избыточным, поскольку у нас имеются адреса запросов (URL
). Мы вполне мо жем использовать эти URL
в качестве ключей.
Однако существуют некоторые ограничения: если вы собираетесь использовать разные URL
для GET/PATCH
, например, вы должны использовать одинаковый ключ, в противном случае, React Query
не сможет сопоставить эти запросы.
Также важно помнить о необходимости включения не только самого URL
, но и всех параметров, которые содержатся в запросе. Комбинация URL
и параметров позволяет создавать уникальные ключи, используемые React Query
для кэширования.
В качестве средства получения данных (fetcher) в приложении используется Axios, которому передается URL
и параметры из queryKey
:
export const useFetch = <T>(
url: string | null,
params?: object,
config?: UseQueryOptions<T, Error, T, QueryKeyT>
) => {
const context = useQuery<T, Error, T, QueryKeyT>(
[url!, params],
({ queryKey }) => fetcher({ queryKey }),
{
enabled: !!url,
...config,
}
);
return context;
};
export const fetcher = <T>({
queryKey,
pageParam,
}: QueryFunctionContext<QueryKeyT>): Promise<T> => {
const [url, params] = queryKey;
return api
.get<T>(url, { params: { ...params, pageParam } })
.then((res) => res.data);
};
Здесь [url!, params]
- это наш ключ, а настройка enabled: !!url
предназначена для отмены выполнения запроса при отсутствии url
(об этом немного позже). В качестве средства получения данных можно использовать что угодно, это не имеет особого значения.
Для улучшения опыта разработк и можно использовать инструменты разработчика React Query
:
import { ReactQueryDevtools } from 'react-query/devtools';
ReactDOM.render(
<React.StrictMode>
<Router>
<QueryClientProvider client={queryClient}>
<App />
<ToastContainer />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</Router>
</React.StrictMode>,
document.getElementById('root')
);
Аутентификация
Для того, чтобы пользоваться нашим приложением, пользователь должен авторизоваться с помощью email и пароля. Сервер возвращает токен, который записывается в куки (в приложении работает любая комбинация email/пароль). Впоследствии токен прикрепляется к каждому запросу.
С помощью токена запрашивается профиль пользователя. В шапке (header) отображается имя пользователя или индикатор загрузки, если запрос находится в стадии выполнения. Интересной частью является то, что мы можем обрабатывать перенаправление на страницу авторизации в корневом компоненте App
, а имя пользователя отображать в отдельном компоненте.
Вот где начинается магия React Query
. С помощью хуков мы легко можем распределять данные о пользователе без их передачи в качестве пропов.
App.tsx
:
const { error } = useGetProfile();
useEffect(() => {
if (error) {
history.replace(pageRoutes.auth);
}
}, [error]);
UserProfile.tsx
:
const UserProfile = ({}: Props) => {
const { data: user, isLoading } = useGetProfile();
if (isLoading) {
return (
<Box display="flex" justifyContent="flex-end">
<CircularProgress color="inherit" size={24} />
</Box>
);
}
return (
<Box display="flex" justifyContent="flex-end">
{user ? `User: ${user.name}` : 'Unauthorized'}
</Box>
);
};
Запрос к API
будет выполняться только один раз (это называется дедупликац ией запросов, о чем мы поговорим в следующем разделе).
Хук для получения данных профиля:
export const useGetProfile = () => {
const context = useFetch<{ user: ProfileInterface }>(
apiRoutes.getProfile,
undefined,
{ retry: false }
);
return { ...context, data: context.data?.user };
};
Настройка retry: false
определяет, что запрос выполняется однократно. Если запрос завершается неудачно, мы считаем, что пользователь неавторизован и выполняем перенаправление.
После ввода пользователем email и пароля отправляется обычный POST-запрос
. Теоретически, мы можем использовать здесь мутации React Query
, но в данном случае у нас нет необходимости определять состояние const [btnLoading, setBtnLoading] = useState(false)
и управлять им, я думаю, что это будет лишним.
Если запрос выполняется успешно, мы инвалидируем все запросы для получения свежих данных. В нашем приложении речь идет об одном запросе - запросе профиля пользователя для обновления имени в шапке:
if (resp.data.token) {
Cookies.set('token', resp.data.token);
history.replace(pageRoutes.main);
queryClient.invalidateQueries();
}
Для инвалидации одного запроса можно использовать queryClient.invalidateQueries(apiRoutes.getProfile)
.
Дедупликация запросов
Предположим, что у нас есть 2 разных компонента, которые обращаются к одинаковой конечной точке API
. Обычно, требуется выполнить 2 идентичных запроса, что является пустой тратой ресурсов сервера. React Query
позволяет дедуплицировать такие запросы. Это означает, что вместо нескольких запросов будет выполнен лишь один.
В приложении есть 2 компонента: компонент, отвечающий за отображение общего количества встреч, и компонент, содержащий список встреч.
Компонент общего количества встреч:
const UsersSummary = () => {
const { data: list, isLoading } = useGetAppointmentsList();
if (!isLoading && !list) {
return null;
}
return (
<Box mb={2}>
<Card>
<Box p={2}>
<Typography>
Total appointments:{' '}
{isLoading ? (
<Skeleton
animation="wave"
variant="rectangular"
height={15}
width="60%"
/>
) : (
list!.pages[0].count
)}
</Typography>
</Box>
</Card>
</Box>
);
};
Компонент списка:
const UsersList = () => {
const {
data: list,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetAppointmentsList();
return (
<>
<Card>
{isLoading ? (
<List>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
</List>
) : (
<List>
{list!.pages.map((page) => (
<React.Fragment key={page.nextId || 0}>
{page.data.map((item) => (
<UserItem
key={item.id}
id={item.id}
name={item.name}
date={item.appointment_date}
/>
))}
</React.Fragment>
))}
</List>
)}
</Card>
{hasNextPage && (
<Box mt={2}>
<Button
variant="contained"
color="primary"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load more users'}
</Button>
</Box>
)}
</>
);
};
Они используют хук useGetAppointmentsList
, который отправляет запрос к API
. Как вы можете увидеть в инструментах разработчика, запрос GET /api/getUserList
выполняется только один раз.
Загрузка дополнительных данных
В приложении имеется бесконечный список с кнопкой Load more
(Загрузить еще). Для реализации такого списка вместо хука useQuery
следует использовать хук useInfiniteQuery
, который позволяет обрабатывать пагинацию с помощью функции fetchNextPage
:
export const useGetAppointmentsList = () =>
useLoadMore<AppointmentInterface[]>(apiRoutes.getUserList);
Наша абстракция для рассматриваемого хука:
export const useLoadMore = <T>(url: string | null, params?: object) => {
const context = useInfiniteQuery<
GetInfinitePagesInterface<T>,
Error,
GetInfinitePagesInterface<T>,
QueryKeyT
>(
[url!, params],
({ queryKey, pageParam = 1 }) => fetcher({ queryKey, pageParam }),
{
getPreviousPageParam: (firstPage) => firstPage.previousId ?? false,
getNextPageParam: (lastPage) => {
return lastPage.nextId ?? false;
},
}
);
return context;
};
Данный хук похож на хук useFetch
, за исключением того, что мы определяем функции getPreviousPageParam
и getNextPageParam
на основе ответа API
и передаем свойство pageParam
в фетчер.
const UsersList = () => {
const {
data: list,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetAppointmentsList();
return (
<>
<Card>
{isLoading ? (
<List>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
</List>
) : (
<List>
{list!.pages.map((page) => (
<React.Fragment key={page.nextId || 0}>
{page.data.map((item) => (
<UserItem
key={item.id}
id={item.id}
name={item.name}
date={item.appointment_date}
/>
))}
</React.Fragment>
))}
</List>
)}
</Card>
{hasNextPage && (
<Box mt={2}>
<Button
variant="contained"
color="primary"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load more users'}
</Button>
</Box>
)}
</>
);
};
Хук useInfiniteQuery
предоставляет несколько дополнительных полей, таких как fetchNextPage
, hasNextPage
, isFetchingNextPage
, а также методы fetchNextPage
и fetchPreviousPage
, которые можно использовать для загрузки дополнительных данных.