React Query
React Query - это библиотека для получения, кеширования, синхронизации и обновления состояния
React-приложениях
, хранящегося на сервере.
Обзор
Установка
yarn add react-query
# или
npm i react-query
Пример
import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
const queryClient = new QueryClient()
export const App = () => (
<QueryClientProvider>
<Example />
</QueryClientProvider>
)
function Example() {
const { isLoading, error, data } = useQuery('repoData', () => fetch('https://api.github.com/repos/tannerlinsley/react-query').then(response => response.json()))
if (isLoading) return <p>Загрузка...</p>
if (error) return <p>Ошибка: {error.message}</p>
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>{' '}
<strong>✨ {data.stargazers_count}</strong>{' '}
<strong>🍴 {data.forks_count}</strong>
</div>
)
}
Быстрый старт
Ключевыми концепциями React Query
являются:
- Запросы (queries)
- Мутации (mutations)
- Инвалидация (аннулирование, признание недействительным) запроса (query invalidation), его кэша
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider
} from 'react-query'
import { getTodos, postTodo } from '../api'
// Создаем клиента
const queryClient = new QueryClient()
const App = () => (
// Передаем клиента в приложение
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
function Todos() {
// Получаем доступ к клиенту
const queryClient = useQueryClient()
// Запрос
const query = useQuery('todos', getTodos)
// Мутация
const mutation = useMutation(postTodo, {
onSuccess: () => {
// Инвалидация и обновление
queryClient.invalidateQueries('todos')
}
})
return (
<div>
<ul>
{query.data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<button
onClick={() => {
mutation.mutate({
id: Date.now(),
title: 'Привет',
})
}}
>
Добавить задачу
</button>
</div>
)
}
render(<App />, document.getElementById('root'))
Инструменты разработчика
По умолчанию инструменты разработчика не включаются в производственную сборку (когда process.env.NODE_ENV === 'production'
).
Существует два режима добавления инструментов в приложение: плавающий (floating) и встроенный (inline).
Плавающий режим
import { ReactQueryDevtools } from 'react-query/devtools'
const App = () => (
<QueryClientProvider client={queryClient}>
{/* Другие компоненты */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
Настройки
initialIsOpen
- еслиtrue
, то панель инструментов по умолчанию будет открытаpanelProps
- используется для добавления пропов к панели, например,className
,style
и т.д.closeButtonProps
- используется для добавления пропов к кнопке закрытия панелиtoggleButtonProps
- используется для добавления пропов к кнопке переключенияposition
: "top-left" | "top-right" | "bottom-left" | "bottom-right" - положение логотипаReact Query
для открытия/закрытия панели
Встроенный режим
import { ReactQueryDevtoolsPanel } from 'react-query/devtools'
const App = () => (
<QueryClientProvider client={queryClient}>
{/* Другие компоненты */}
<ReactQueryDevtoolsPanel style={styles} className={className} />
</QueryClientProvider>
)
Важные настройки по умолчанию
- Экземпляры запроса(query instances), созданные с помощью
useQuery
илиuseInfiniteQuery
по умолчанию считают кэшированные данные устаревшими (stale) - Устаревшие запросы автоматически повторно выполняются в фоновом режиме (background) в следующих случаях:
- Монтирование нового экземпляра запроса
- Переключения окна в браузере
- Повторное подключение к сети
- Когда задан интервал выполнения повторного запроса (refetch interval)
- Результаты запроса, не имеющие активных экземпляров
useQuery
,useInfiniteQuery
или наблюдателей (observers) помечаются как "неактивные" (inactive) и остаются в кэше - По умолчанию запросы, помеченные как неактивные, уничтожаются сборщиком мусора через 5 минут
- Провалившиеся запросы автоматически повторно выполняются 3 раза с экспоненциально увеличивающейся задержкой перед передачей ошибки в UI
- Результаты запросов по умолчанию структурно распределяются для определения того, действительно ли данные изменились, и если это не так, ссылка на данные остается неизменной, что способствует стабилизации данных при использовании
useMemo
иuseCallback
Запросы
Основы
Запрос - это декларативная зависимость асинхронного источника данных, связанного с уникальным ключом. Запрос может использоваться с любым основанным на промисе методом (включая методы GET
и POST
) получения данных от сервера. Если метод изменяет данные на сервере, то лучше использовать мутации.
Для подписки на запрос в компоненте или пользовательском хуке следует вызвать хук useQuery
со следующими параметрами:
- Уникальный ключ запроса
- Функция, возвращающая промис, который
- разрешается данными (resolve data) или
- выбрасывает исключение (throw error)
import { useQuery } from 'react-query'
function App() {
const info = useQuery('todos', fetchTodoList)
}
Уникальный ключ используется для выполнения повторных запросов, кэширования и распределения запросов в приложении.
Результаты запроса, возвращаемые useQuery
, содержат всю необходимую информацию о запросе.
const result = useQuery('todos', fetchTodos)
Объект result
содержит несколько важных состояний (states). Запрос может находиться в одном из следующих состояний:
isLoading
илиstatus === 'loading'
- запрос находится на стадии выполнения, данные еще не полученыisError
илиstatus === 'error'
- запрос завершился ошибкойisSuccess
илиstatus === 'success'
- запрос завершился успешно, данные доступныisIdle
илиstatus === 'idle'
- запрос отключен
Кроме того, объект result
содержит следующую информацию:
error
- если запрос находится в состоянииisError
, ошибка доступна через свойствоerror
data
- если запрос находится в состоянииsuccess
, данные доступны через свойствоdata
isFetching
- в любом состоянии, если запрос находится на стадии выполнения (включая фоновый по вторный запрос)isFetching
будет иметь значениеtrue
Для большинства запросов имеет смысл сначала проверять состояние isLoading
, затем состояние isError
и, наконец, использовать данные для рендеринга:
function Todos() {
const { isLoading, isError, data, error } = useQuery('todos', fetchTodoList)
if (isLoading) {
return <span>Загрузка...</span>
}
if (isError) {
return <span>Ошибка: {error.message}</span>
}
// На данном этапе мы можем предположить, что `isSuccess === true`
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
Вместо логических значений можно использовать состояние status
:
function Todos() {
const { status, data, error } = useQuery('todos', fetchTodoList)
if (status === 'loading') {
return <span>Загрузка...</span>
}
if (status === 'error') {
return <span>Ошибка: {error.message}</span>
}
// status === 'success'
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
Ключи запроса
React Query
осуществляет кэширование запросов на основе ключей. Ключи могут быть любыми уникальными сериализуемыми значениями (строками, массивами, объектами и т.д.).
Строковые ключи
При передачи строки в качестве ключа запроса, она преобразуется в массив с единственным элементом. Данный формат может использоваться для:
- Общих ресурсов списка/индекса
- Неиерархических ресурсов
// Список задач
useQuery('todos', ...) // queryKey === ['todos']
Ключи в виде массива
Когда для описания данных требуется больше информации, в качестве ключа запроса можно передать массив со строкой и любым количеством сериализуемых объектов. Такой формат может использоваться для:
- Иерархических или вложенных ресурсов
- В этом случае, обычно, передается идентификатор, индекс или другой примитив для определения элемента
- Запросов с дополнительными параметрами
- В этом случае, как правило, передается объект с дополнительными настройками
// Конкретная задача
useQuery(['todo', 5], ...)
// queryKey === ['todo', 5]
// Конкретная задача в формате "превью"
useQuery(['todo', 5, { preview: true }], ...)
// queryKey === ['todo', 5, { preview: true }]
// Список выполненных задач
useQuery(['todos', { type: 'done' }], ...)
// queryKey === ['todos', { type: 'done' }]
Ключи хешируются детерменировано
Это означает, что порядок расположения ключей в объекте не имеет значения:
// Эти запросы идентичны
useQuery(['todos', { status, page }], ...)
useQuery(['todos', { page, status }], ...)
useQuery(['todos', { page, status, other: undefined }], ...)
А порядок расположения ключей в массиве, напротив, имеет значение:
// Эти запросы не идентичны
useQuery(['todos', status, page], ...)
useQuery(['todos', page, status], ...)
useQuery(['todos', undefined, page, status], ...)
Обратите внимание, что если функция запроса использует переменную, такая переменная должна включаться в ключ запроса:
function Todos({ todoId }) {
const result = useQuery(['todos', todoId], () => fetchTodoById(todoId))
}
Функции запроса
Функция запроса может быть любой функцией, возвращающей промис, который, в свою очередь, должен либо разрешаться данными, либо выбрасывать исключение:
useQuery(['todos', todoId], fetchTodoById)
useQuery(['todos', todoId], () => fetchTodoById(todoId))
useQuery(['todos', todoId], async () => {
const data = await fetchTodoById(todoId)
return data
})
Обработка ошибок
При возникновении ошибки, функция запроса должна выбрасывать исключение, которое сохраняется в состоянии error
запроса:
const { data, error } = useQuery(['todos', todoId], async () => {
const response = await fetch('/todos/' + todoId)
if (!response.ok) {
throw new Error('Что-то пошло не так')
}
return await response.json()
})
Переменные функции запроса
При необходимости, в функции запроса можно получить доступ к переменным, указанным в ключе запроса:
function Todos({ status, page }) {
const result = useQuery(['todos', { status, page }], fetchTodoList)
}
// Получаем переменные `key`, `status` и `page` в функции запроса
function fetchTodoList({ queryKey }) {
const [_key, { status, page }] = queryKey
return new Promise()
}
Использование объекта вместо параметров
Для настройки запроса можно использовать объект:
import { useQuery } from 'react-query'
useQuery({
queryKey: ['todo', 5],
queryFn: fetchTodo,
...config,
})
Параллельные запросы
"Параллельными" называются запросы, которые выполняются одновременно.
Когда количество запросов остается неизменным, для их параллельного выполнения достаточно указать несколько хуков useQuery
или useInfiniteQuery
:
function App () {
// Эти запросы будут выполнены одновременно
const usersQuery = useQuery('users', fetchUsers)
const teamsQuery = useQuery('teams', fetchTeams)
const projectsQuery = useQuery('projects', fetchProjects)
...
}
Когда количество запросов меняется от рендеринга к рендерингу, для их параллельного выполнения следует использовать хук useQueries
. Он принимает массив объектов с настройками запроса и возвращает массив результатов запросов:
function App({ users }) {
const userQueries = useQueries(
users.map(user => ({
queryKey: ['user', user.id],
queryFn: () => fetchUserById(user.id),
}))
)
}
Зависимые запросы
Начало выполнения зависимых (или последовательных) запросов зависит от окончания вы полнения предыдущих запросов. Для реализации последовательного выполнения запросов используется настройка enabled
:
// Получаем пользователя
const { data: user } = useQuery(['user', email], getUserByEmail)
const userId = user?.id
// Затем получаем проекты пользователя
const { isIdle, data: projects } = useQuery(
['projects', userId],
getProjectsByUser,
{
// Запрос не будет выполняться до получения userId
enabled: !!userId,
}
)
Индикаторы получения данных в фоновом режиме
В большинстве случаев для отображения индикатора загрузки во время получения данных достаточно состояния status === 'loading'
. Но иногда может потребоваться отображать индикатор для запроса, выполняющегося в фоновом режиме. Для этого запросы предоставляют дополнительное логическое значение isFetching
:
function Todos() {
const { status, data: todos, error, isFetching } = useQuery(
'todos',
fetchTodos
)
return status === 'loading' ? (
<span>Загрузка...</span>
) : status === 'error' ? (
<span>Ошибка: {error.message}</span>
) : (
<>
{isFetching ? <p>Обновление...</p> : null}
<div>
{todos.map(todo => (
<Todo todo={todo} />
))}
</div>
</>
)
}
Для отображения глобального индикатора загрузки при выполнении любого запроса (в том числе, в фоновом режиме) можно использовать хук useIsFetching
:
import { useIsFetching } from 'react-query'
function GlobalLoadingIndicator() {
const isFetching = useIsFetching()
return isFetching ? (
<p>Запрос выполняется в фоновом режиме...</p>
) : null
}
Выполнение повторного запроса при фокусировке на окне
Если пользователь покидает приложение и возвращается к устаревшим данным, React Query
автоматически запрашивает свежие данные в фоновом режиме. Это поведение можно отключить глобально или в отношении конкретного запроса с помощью настройки refetchOnWindowFocus
.
Глобальное отключение:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
}
}
})
function App() {
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
Отключение для запроса:
useQuery('todos', fetchTodos, { refetchOnWindowFocus: false })
Отключение/приостановка выполнения запросов
Для отключения автоматического выполнения запроса используется настройка enabled
.
При установка значения данной настройки в false
:
- Если запрос имеет кэшированные данные
- Запрос будет инициализирован в состоянии
status === 'success'
илиisSuccess
- Запрос будет инициализирован в состоянии
- Если запрос не имеет таких данных
- Запрос бует запущен в состоянии
status === 'idle'
илиisIdle
- Запрос бует запущен в состоянии
- Запрос не будет автоматически выполняться при монтировании
- Запрос не будет автоматически выполняться повторно при монтировании или появлении нового экземпляра
- Запрос будет игнорировать вызовы
invalidateQueries
иrefetchQueries
на клиенте запроса (query client), обычно, приводящие к выполнению повторного запроса - Для ручного выполнения запроса может быть использован метод
refetch
function Todos() {
const {
isIdle,
isLoading,
isError,
data,
error,
refetch,
isFetching,
} = useQuery('todos', fetchTodoList, {
enabled: false,
})
return (
<>
<button onClick={() => refetch()}>Получить задачи</button>
{isIdle ? (
<span>Не готов...</span>
) : isLoading ? (
<span>Загрузка...</span>
) : isError ? (
<span>Ошибка: {error.message}</span>
) : (
<>
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<div>{isFetching ? 'Выполнение запроса...' : null}</div>
</>
)}
</>
)
}
Повторное выполнение провалившихся запросов
При провале запроса, автоматически выполняется 3 попытки его повторного выполнения.
Это поведение можно изменить как на глобальном уровне, так и на уровне конкретного запроса:
- Установка
retry: false
отключает повторы - Установка
retry: 6
увеличивает количество повторов до 6 - Установка
retry: true
делает повторы бесконечными - Устанока
retry: (failureCount, error) => {}
позволяет кастомизировать обработку провала
import { useQuery } from 'react-query'
// Увеличиваем количество повторов для конкретного запроса
const result = useQuery(['todos', 1], fetchTodoListPage, {
retry: 10, // Перед отображением ошибки будет выполнено 10 попыток
})
По умолчанию задержки между повторами составляют 2000, 4000 и 6000 мс. Это поведение можно изменить с помощью настройки retryDelay
.
Запросы для пагинации
Для реализации пагинации с помощью React Query
достаточно включить информацию о странице в ключ запроса:
const result = useQuery(['projects', page], fetchProjects)
Тем не менее, если запустить данный пример, то обнаружится, что UI перепрыгивает между состояниями success
и loading
, поскольку каждая новая страница расценивается как новый запрос.
Для решения этой проблемы React Query
предоставляет keepPreviousData
. Установка данной настройки в значение true
приводит к следующему:
- Данные последнего успешного запроса остаются доступн ыми во время запроса новых данных
- После получения новых данных старые
data
мягко ими заменяются isPreviousData
позволяет определять, какие данные предоставляются текущим запросом
function Todos() {
const [page, setPage] = React.useState(0)
const fetchProjects = (page = 0) => fetch('/api/projects?page=' + page)
const {
isLoading,
isError,
error,
data,
isFetching,
isPreviousData,
} = useQuery(['projects', page], () => fetchProjects(page), { keepPreviousData : true })
return (
<div>
{isLoading ? (
<span>Загрузка...</span>
) : isError ? (
<span>Ошибка: {error.message}</span>
) : (
<div>
{data.projects.map(project => (
<p key={project.id}>{project.name}</p>
))}
</div>
)}
<span>Текущая страница: {page + 1}</span>
<button
onClick={() => setPage(old => Math.max(old - 1, 0))}
disabled={page === 0}
>
Предыдущая страница
</button>{' '}
<button
onClick={() => {
if (!isPreviousData && data.hasMore) {
setPage(old => old + 1)
}
}}
// Отключаем кнопку, пока следующая страница не будет доступной
disabled={isPreviousData || !data.hasMore}
>
Следующая страница
</button>
{isFetching ? <span>Загрузка...</span> : null}{' '}
</div>
)
}
keepPreviousData
также работает с хуком useInfiniteQuery
, что позволяет пользователям продолжать просмотр кэшированных данных при изменении ключей запроса.
Бесконечные запросы
Для запроса бесконечных списков (например, "загрузить еще" или "бесконечная прокрутка") React Query
предоставляет специальную версию useQuery
- useInfiniteQuery
.
Особенности использования useInfiniteQuery
:
data
- объект, содержащий бесконечные данные:data.pages
- массив полученных страницdata.pageParams
- массив параметров страницы, использованных для запроса страниц
- Доступны функции
fetchNextPage
иfetchPreviousPage
- Доступны настройки
getNextPageParam
иgetPreviousPageParam
, позволяющие определить наличие данных для загрузки - Доступно логическое значение
hasNextPage
. Если оно равняетсяtrue
,getNextPageParam
вернет значение, а неundefined
- Доступно логическое значение
hasPreviousPage
. Если оно равняетсяtrue
,getPreviousPageParam
вернет значение, а неundefined
- Доступны логические значения
isFetchingNextPage
иisFetchingPreviousPage
, позволяющие различать состояние фонового обновления и состояние дополнительной загрузки
Примеры
Предположим, что у нас имеется API, возвращающий страницы с тремя projects
за раз на основе индекса cursor
, а также сам курсор, который может быть использован для получения следующей группы проектов:
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
Располагая этими сведениями, мы можем реализовать UI "Загрузить еще" посредством:
- Ожидания, пока
useInfiniteQuery
выполнить запрос на получение первой порции данных по умолчанию - Возвращения информации для следующего запроса в
getNextPageParam
- Вызова функции
fetchNextPage
import { useInfiniteQuery } from 'react-query'
function Projects() {
const fetchProjects = ({ pageParam = 0 }) =>
fetch('/api/projects?cursor=' + pageParam)
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
status,
} = useInfiniteQuery('projects', fetchProjects, {
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
return status === 'loading' ? (
<p>Загрузка...</p>
) : status === 'error' ? (
<p>Ошибка: {error.message}</p>
) : (
<>
{data.pages.map((group, i) => (
<React.Fragment key={i}>
{group.projects.map(project => (
<p key={project.id}>{project.name}</p>
))}
</React.Fragment>
))}
<div>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Загрузка дополнительных данных...'
: hasNextPage
? 'Загрузить еще'
: 'Больше нечего загружать'}
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? 'Выполнение запроса...' : null}</div>
</>
)
}
По умолчанию переменная, возвращаемая из getNextPageParam
, передается в функцию запроса. Пользовательская переменная, переданная в fetchNextPage
, перезаписывает дефолтную:
function Projects() {
const fetchProjects = ({ pageParam = 0 }) =>
fetch('/api/projects?cursor=' + pageParam)
const {
status,
data,
isFetching,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery('projects', fetchProjects, {
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})
// Передаем собственный параметр страницы
const skipToCursor50 = () => fetchNextPage({ pageParam: 50 })
}
Двунаправленный бесконечный список можно реализовать с помощью getPreviousPageParam
, fetchPreviousPage
, hasPreviousPage
и isFetchingPreviousPage
:
useInfiniteQuery('projects', fetchProjects, {
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
Ручное удаление первой страницы:
queryClient.setQueryData('projects', data => ({
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1),
}))
Ручное удаление значения из конкретной страницы:
const newPagesArray = []
oldPagesArray?.pages.forEach(page => {
const newData = page.data.filter(val => val.id !== updatedId)
newPagesArray.push({ data: newData, pageParam: page.pageParam })
})
queryClient.setQueryData('projects', data => ({
pages: newPagesArray,
pageParams: data.pageParams,
}))
Заменители данных запроса
Данные-заменители (placeholder data) позволяют запросам рендерить частичный ("фейковый") контент до получения настоящих данных в фоновом режиме. Это дает результат, похожий на использование настройки initialData
, но при этом данные не сохраняются в кэше.
Существует два способа помещения дан ных в кэш для использования в качестве заменителей:
- Декларативный:
- Предоставление запросу
placeholderData
для заполнения пустого кэша
- Предоставление запросу
- Императивный:
- Предварительное или обычное получение данных с помощью
queryClient
и настройкиplaceholderData
- Предварительное или обычное получение данных с помощью
Данные-заменители как значение
function Todos() {
const result = useQuery('todos', () => fetch('/todos'), {
placeholderData: placeholderTodos,
})
}
Данные-заменители как функция
Если процесс получения данных-заменителей является интенсивным или мы не хотим, чтобы он повторялся при каждом рендеринге, тогда в качестве значения placeholderData
можно мемоизировать значение или передать мемоизированную функцию:
function Todos() {
const placeholderData = useMemo(() => generateFakeTodos(), [])
const result = useQuery('todos', () => fetch('/todos'), { placeholderData })
}
Данные-заменители из кэша
В некоторых случаях может потребоваться использовать в качестве данных-заменителей кэшированные результаты другого запроса. Хорошим примером такого случая является поиск кэшированных данных списка запросов поста в блоге для превью поста и последующее использование таких данных в качестве заменителя для запроса на получение конкретного поста:
function Todo({ blogPostId }) {
const result = useQuery(['blogPost', blogPostId], () => fetch('/blogPosts'), {
placeholderData: () => {
// Используем превью blogPost из запроса 'blogPosts'
// в качестве заменителя для запроса на получение данного blogPost
return queryClient
.getQueryData('blogPosts')
?.find(d => d.id === blogPostId)
},
})
}