Query endpoints

Query endpoints в RTK Query предназначены для получения данных с сервера. Каждый query endpoint описывает:

  • URL запроса;
  • HTTP-метод;
  • параметры;
  • преобразование ответа;
  • правила кеширования;
  • автоматическое обновление данных;
  • интеграцию с React Hooks.

Query endpoints являются центральной частью API-среза (api slice) и определяют способ взаимодействия клиентского приложения с сервером.

Базовый endpoint создаётся внутри createApi через секцию endpoints.

import { createApi, fetchBaseQuery } fr om '@reduxjs/toolkit/query/react'

export const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({
        baseUrl: 'https://jsonplaceholder.typicode.com'
    }),
    endpoints: (builder) => ({
        getPosts: builder.query({
            query: () => '/posts'
        })
    })
})

В данном примере:

  • getPosts — имя query endpoint;
  • builder.query() — создание query endpoint;
  • query() — функция генерации HTTP-запроса.

Структура builder.query

Метод builder.query() принимает объект конфигурации.

builder.query({
    query,
    transformResponse,
    transformErrorResponse,
    providesTags,
    keepUnusedDataFor,
    extraOptions,
    async onQueryStarted(),
    async onCacheEntryAdded()
})

Основные свойства:

Свойство Назначение
query Описание HTTP-запроса
transformResponse Изменение успешного ответа
transformErrorResponse Изменение ошибки
providesTags Связь с системой тегов
keepUnusedDataFor Время хранения кеша
onQueryStarted Побочные эффекты
onCacheEntryAdded Работа с жизненным циклом кеша

Простейший query endpoint

getUsers: builder.query({
    query: () => '/users'
})

RTK Query автоматически:

  • отправляет запрос;
  • кеширует ответ;
  • отслеживает загрузку;
  • предотвращает дублирование запросов;
  • обновляет компоненты при изменении данных.

Использование query endpoints в React

После создания endpoint RTK Query автоматически генерирует React Hook.

export const {
    useGetUsersQuery
} = api

Использование:

function Users() {
    const { data, error, isLoading } = useGetUsersQuery()

    if (isLoading) {
        return <div>Loading...</div>
    }

    if (error) {
        return <div>Error</div>
    }

    return (
        <ul>
            {data.map(user => (
                <li key={user.id}>
                    {user.name}
                </li>
            ))}
        </ul>
    )
}

Передача параметров

Query endpoint может принимать аргументы.

getPost: builder.query({
    query: (id) => `/posts/${id}`
})

Использование:

const { data } = useGetPostQuery(5)

RTK Query использует аргумент как часть ключа кеша.

Например:

useGetPostQuery(1)
useGetPostQuery(2)

создают два независимых кеша.


Query object

Функция query() может возвращать не только строку, но и объект конфигурации.

getPost: builder.query({
    query: (id) => ({
        url: `/posts/${id}`,
        method: 'GET'
    })
})

Query parameters

getPosts: builder.query({
    query: (page = 1) => ({
        url: '/posts',
        params: {
            page
        }
    })
})

Результирующий запрос:

/posts?page=1

Headers

getProfile: builder.query({
    query: () => ({
        url: '/profile',
        headers: {
            Authorization: 'Bearer token'
        }
    })
})

Dynamic headers

Чаще всего заголовки задаются через prepareHeaders.

const baseQuery = fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.token

        if (token) {
            headers.set('Authorization', `Bearer ${token}`)
        }

        return headers
    }
})

Query arguments

Аргумент endpoint может быть любого типа:

getPosts: builder.query({
    query: ({ page, lim it }) => ({
        url: '/posts',
        params: {
            page,
            limit
        }
    })
})

Использование:

useGetPostsQuery({
    page: 1,
    limit: 20
})

Сериализация аргументов

RTK Query сериализует аргументы запроса для генерации cache key.

useGetPostsQuery({
    page: 1
})

и

useGetPostsQuery({
    page: 1
})

используют одинаковый cache key.


Разделение кеша

Разные аргументы создают разные записи кеша.

useGetPostsQuery({ page: 1 })
useGetPostsQuery({ page: 2 })

Query lifecycle

Query endpoint проходит несколько стадий:

  1. Инициализация;
  2. Отправка запроса;
  3. Ожидание ответа;
  4. Успех или ошибка;
  5. Кеширование;
  6. Повторное использование кеша;
  7. Удаление кеша.

Состояния query hook

RTK Query предоставляет множество флагов состояния.

const {
    data,
    error,
    isLoading,
    isFetching,
    isSuccess,
    isError
} = useGetUsersQuery()

isLoading

isLoading активен только при первом запросе.

if (isLoading) {
    return <Spinner />
}

isFetching

isFetching активен при любом повторном запросе.

if (isFetching) {
    console.log('Background refetch')
}

isSuccess

if (isSuccess) {
    console.log(data)
}

isError

if (isError) {
    console.log(error)
}

Повторное использование кеша

Если компонент вызывает:

useGetUsersQuery()

и данные уже находятся в кеше, RTK Query:

  • не отправит повторный запрос;
  • возьмёт данные из store;
  • синхронизирует подписки.

Подписки на кеш

Каждый hook создаёт подписку на cache entry.

Если несколько компонентов используют одинаковый query:

useGetUsersQuery()

RTK Query хранит один кеш и несколько подписчиков.


Удаление кеша

После удаления последнего подписчика запускается таймер очистки кеша.

По умолчанию:

60 секунд

Настройка:

getUsers: builder.query({
    query: () => '/users',
    keepUnusedDataFor: 300
})

Автоматический refetch

RTK Query умеет автоматически обновлять данные.


refetchOnMountOrArgChange

useGetUsersQuery(undefined, {
    refetchOnMountOrArgChange: true
})

refetchOnFocus

const { data } = useGetUsersQuery(undefined, {
    refetchOnFocus: true
})

Повторный запрос произойдёт при возврате во вкладку браузера.


refetchOnReconnect

const { data } = useGetUsersQuery(undefined, {
    refetchOnReconnect: true
})

Polling

RTK Query поддерживает периодическое обновление.

const { data } = useGetNotificationsQuery(undefined, {
    pollingInterval: 5000
})

Запрос выполняется каждые 5 секунд.


Skip query

Иногда запрос необходимо отключить.

const { data } = useGetUserQuery(id, {
    skip: !id
})

skipToken

Для TypeScript и сложных условий используется skipToken.

import { skipToken } fr om '@reduxjs/toolkit/query'

const result = useGetUserQuery(id ?? skipToken)

selectFromResult

Позволяет минимизировать лишние рендеры.

const { user } = useGetUsersQuery(undefined, {
    selectFromResult: ({ data }) => ({
        user: data?.find(user => user.id === 5)
    })
})

transformResponse

Позволяет изменить серверный ответ.

getUsers: builder.query({
    query: () => '/users',
    transformResponse: (response) => {
        return response.data
    }
})

Нормализация данных

transformResponse: (response) => {
    const entities = {}

    response.forEach(user => {
        entities[user.id] = user
    })

    return entities
}

transformErrorResponse

getUsers: builder.query({
    query: () => '/users',
    transformErrorResponse: (response) => {
        return response.status
    }
})

providesTags

Query endpoints могут предоставлять теги.

getPosts: builder.query({
    query: () => '/posts',
    providesTags: ['Posts']
})

Динамические теги

getPosts: builder.query({
    query: () => '/posts',
    providesTags: (result) =>
        result
            ? [
                ...result.map(post => ({
                    type: 'Posts',
                    id: post.id
                })),
                { type: 'Posts', id: 'LIST' }
            ]
            : [{ type: 'Posts', id: 'LIST' }]
})

Точечная инвалидация

Mutation endpoint может инвалидировать конкретный тег.

updatePost: builder.mutation({
    query: (post) => ({
        url: `/posts/${post.id}`,
        method: 'PUT',
        body: post
    }),
    invalidatesTags: (result, error, post) => [
        { type: 'Posts', id: post.id }
    ]
})

Кеширование по сущностям

Такой подход позволяет:

  • обновлять только изменённые данные;
  • избегать полного refetch;
  • уменьшать сетевую нагрузку.

queryFn

Вместо query можно использовать queryFn.

getUser: builder.query({
    async queryFn(id) {
        try {
            const response = await fetch(`/users/${id}`)
            const data = await response.json()

            return { data }
        } catch (error) {
            return { error }
        }
    }
})

Отличия query и queryFn

query queryFn
Простые HTTP-запросы Полный контроль
Использует baseQuery Может не использовать baseQuery
Минимум кода Гибкая логика

onQueryStarted

Позволяет выполнять побочные эффекты.

getUsers: builder.query({
    query: () => '/users',

    async onQueryStarted(arg, api) {
        console.log('Request started')

        try {
            await api.queryFulfilled
            console.log('Success')
        } catch {
            console.log('Error')
        }
    }
})

Структура api в onQueryStarted

async onQueryStarted(arg, {
    dispatch,
    getState,
    queryFulfilled,
    requestId,
    extra,
    getCacheEntry
}) {

}

Оптимистичное обновление

Хотя оптимистичные обновления чаще используются в mutation endpoints, query endpoints тоже могут работать с кешем.

async onQueryStarted(id, { dispatch, queryFulfilled }) {
    const patchResult = dispatch(
        api.util.updateQueryData(
            'getPost',
            id,
            draft => {
                draft.views++
            }
        )
    )

    try {
        await queryFulfilled
    } catch {
        patchResult.undo()
    }
}

onCacheEntryAdded

Позволяет реагировать на создание кеша.


WebSocket integration

getMessages: builder.query({
    query: () => '/messages',

    async onCacheEntryAdded(
        arg,
        {
            updateCachedData,
            cacheDataLoaded,
            cacheEntryRemoved
        }
    ) {
        const socket = new WebSocket('ws://localhost:3000')

        try {
            await cacheDataLoaded

            socket.onmess age = (event) => {
                const message = JSON.parse(event.data)

                updateCachedData((draft) => {
                    draft.push(message)
                })
            }
        } catch {}

        await cacheEntryRemoved

        socket.close()
    }
})

updateCachedData

Метод updateCachedData использует Immer.

updateCachedData((draft) => {
    draft.push(newMessage)
})

Можно мутировать draft напрямую.


cacheDataLoaded

Промис выполняется после первой успешной загрузки данных.

await cacheDataLoaded

cacheEntryRemoved

Позволяет ожидать удаление кеша.

await cacheEntryRemoved

Ленивые запросы

RTK Query поддерживает lazy queries.

const [
    trigger,
    result
] = useLazyGetUsersQuery()

Выполнение lazy query

<button onCl ick={() => trigger()}>
    Load users
</button>

Prefetch

RTK Query поддерживает предварительную загрузку данных.

const prefetchPosts = api.usePrefetch('getPosts')

Использование prefetch

<button
    onMouseEn ter={() => prefetchPosts()}
>
    Open posts
</button>

forceRefetch

prefetchPosts(undefined, {
    force: true
})

useQueryState

Позволяет получать только состояние query.

const result = api.endpoints.getUsers.useQueryState()

useQuerySubscription

Подписка без чтения данных.

api.endpoints.getUsers.useQuerySubscription()

Серверные ошибки

Стандартная ошибка RTK Query:

{
    status: 404,
    data: {
        message: 'Not found'
    }
}

Обработка ошибок

if (error) {
    console.log(error.status)
}

Unwrap

Для lazy queries доступен unwrap().

try {
    const data = await trigger().unwrap()
} catch (error) {
    console.log(error)
}

Query endpoint с полной конфигурацией

getPosts: builder.query({
    query: ({ page, lim it }) => ({
        url: '/posts',
        method: 'GET',
        params: {
            page,
            limit
        }
    }),

    transformResponse: (response) => {
        return response.items
    },

    providesTags: (result) =>
        result
            ? [
                ...result.map(post => ({
                    type: 'Posts',
                    id: post.id
                })),
                { type: 'Posts', id: 'LIST' }
            ]
            : [{ type: 'Posts', id: 'LIST' }],

    keepUnusedDataFor: 120,

    async onQueryStarted(arg, {
        queryFulfilled
    }) {
        try {
            await queryFulfilled
        } catch (error) {
            console.log(error)
        }
    }
})

Архитектурные рекомендации

Разделение endpoints

Крупные API лучше разбивать логически.

/users
/posts
/comments
/auth

Единый api slice

Обычно приложение использует один createApi.

export const api = createApi({
    reducerPath: 'api',
    baseQuery,
    tagTypes: ['Posts', 'Users'],
    endpoints: () => ({})
})

injectEndpoints

Для code splitting используется injectEndpoints.

const extendedApi = api.injectEndpoints({
    endpoints: (builder) => ({
        getUsers: builder.query({
            query: () => '/users'
        })
    })
})

Производительность query endpoints

RTK Query оптимизирует:

  • дедупликацию запросов;
  • повторное использование кеша;
  • подписки;
  • автоматический garbage collection;
  • минимизацию ререндеров;
  • фоновые refetch-запросы.

Типичные ошибки

Создание новых объектов

Проблема:

useGetPostsQuery({
    page: currentPage
})

Если объект создаётся нестабильно, возможны лишние операции сериализации.


Отсутствие тегов

Без providesTags автоматическая инвалидация не работает.


Хранение derived state

Нежелательно копировать query data в local state.

Плохо:

const [users, setUsers] = useState([])

useEffect(() => {
    setUsers(data)
}, [data])

RTK Query уже предоставляет реактивное состояние.


Сравнение query endpoints и mutation endpoints

Query Mutation
Получение данных Изменение данных
Кешируются автоматически Обычно вызывают инвалидацию
GET-запросы POST/PUT/PATCH/DELETE
useQuery hooks useMutation hooks

Внутреннее устройство query endpoints

Каждый query endpoint внутри RTK Query:

  1. Создаёт action creators;
  2. Создаёт async thunk;
  3. Создаёт selectors;
  4. Управляет cache lifecycle;
  5. Хранит данные в Redux store;
  6. Генерирует React hooks;
  7. Выполняет подписки;
  8. Сериализует аргументы;
  9. Отслеживает invalidation tags;
  10. Управляет refetch-механизмами.

Store structure

Пример внутреннего состояния:

{
    api: {
        queries: {
            'getPosts(undefined)': {
                status: 'fulfilled',
                data: [...],
                error: null
            }
        }
    }
}

Ключевые преимущества query endpoints

  • Минимизация boilerplate;
  • Автоматическое кеширование;
  • Централизованная работа с сервером;
  • Управление жизненным циклом запросов;
  • Интеграция с Redux;
  • Поддержка polling;
  • Background refetch;
  • Streaming updates;
  • Optimistic updates;
  • Deduplication;
  • Cache invalidation;
  • SSR compatibility;
  • Lazy loading;
  • Prefetching;
  • Fine-grained subscriptions.