Тема
InfiniteQueryStore
InfiniteQueryStore предназначен для cursor pagination: backend возвращает страницу данных и cursor следующей страницы, а store накапливает страницы в одном observable-состоянии.
Создание: createInfiniteQueryStore(...). Factory автоматически запускает первую страницу, если autoStart !== false.
Формат ответа backend
ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
import { authApi } from "../../api"
export interface FeedEvent {
id: string
title: string
}
// Одна страница содержит элементы и cursor следующей страницы.
export interface FeedPage {
items: FeedEvent[]
nextCursor: string | null
}
export const getFeedPageApi = (
cursor: string | null,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<FeedPage>> => {
return authApi.get<FeedPage>("/api/v1/feed", {
// Сохраняем signal и остальные настройки, переданные store.
...config,
// Первая страница отправляется без cursor.
params: cursor === null ? undefined : { cursor },
})
}Domain store
ts
import { makeAutoObservable } from "mobx"
import { createInfiniteQueryStore, type INormalizedAxiosError } from "@dubium/query-layer"
import { getFeedPageApi, type FeedEvent, type FeedPage } from "../api/feed.api"
export class FeedStore {
private readonly requestHandler = createInfiniteQueryStore<FeedPage>({
// Все страницы одной ленты принадлежат одному query.
queryKey: () => ["feed", "events"],
// Параметр первой страницы.
initialPageParam: null,
// Store передаёт pageParam и AbortSignal.
queryFn: ({ pageParam, signal }) => {
// pageParam имеет тип unknown, поэтому проверяем его до API-вызова.
const cursor = typeof pageParam === "string" ? pageParam : null
return getFeedPageApi(cursor, { signal })
},
// После страницы store спрашивает, какой параметр использовать дальше.
// undefined означает, что следующей страницы больше нет.
getNextPageParam: (lastPage) => {
return lastPage.nextCursor ?? undefined
},
// Не держим в памяти больше пяти страниц.
maxPages: 5,
// Уже загруженные страницы свежи 30 секунд.
staleTime: 30_000,
})
constructor() {
makeAutoObservable<this, "requestHandler">(
this,
{
// Вложенный InfiniteQueryStore уже observable.
requestHandler: false,
},
{
autoBind: true,
},
)
}
// Превращаем массив страниц в плоский список для UI.
get events(): FeedEvent[] {
return this.requestHandler.pages.flatMap((page) => page.items)
}
get loading(): boolean {
return this.requestHandler.loading
}
get loadingMore(): boolean {
return this.requestHandler.isFetchingNextPage
}
get hasMore(): boolean {
return this.requestHandler.hasNextPage
}
get error(): INormalizedAxiosError | null {
return this.requestHandler.error
}
async fetchNext(): Promise<void> {
// Защищаемся от повторного клика и отсутствующей страницы.
if (!this.hasMore || this.loadingMore) {
return
}
await this.requestHandler.fetchNextPage()
}
async refresh(): Promise<void> {
// refetch принудительно обновляет infinite query.
await this.requestHandler.refetch()
}
dispose(): void {
this.requestHandler.dispose()
}
}Как работает cursor
На первой загрузке pageParam === null. После ответа:
ts
getNextPageParam(lastPage)возвращает lastPage.nextCursor. При следующем вызове fetchNextPage() это значение попадёт в queryFn({ pageParam }). Если функция вернула undefined, hasNextPage станет false.
Cursor не нужно добавлять в queryKey: все страницы одной ленты хранятся в одной записи. Но фильтр, сортировку и пользователя в ключ добавлять нужно:
ts
queryKey: () => ["feed", "events", { category: this.category }]При смене категории это уже другая лента и другая запись cache.
Использование в React
ts
import { useEffect, useState } from "react"
import { observer } from "mobx-react-lite"
import { FeedStore } from "../../data/feed/model/feed.store"
export const Feed = observer(() => {
// Один store на lifecycle ленты.
const [store] = useState(() => new FeedStore())
useEffect(() => () => store.dispose(), [store])
if (store.loading) {
return <p>Загружаем ленту…</p>
}
return (
<section>
{store.error && <p role="alert">{store.error.message}</p>}
<ul>
{store.events.map((event) => (
<li key={event.id}>{event.title}</li>
))}
</ul>
<button disabled={!store.hasMore || store.loadingMore} onClick={() => void store.fetchNext()}>
{store.loadingMore ? "Загружаем…" : store.hasMore ? "Показать ещё" : "Больше событий нет"}
</button>
</section>
)
})Конфигурация InfiniteQueryStore
| Поле | Type | Default | Что делает |
|---|---|---|---|
queryKey | () => readonly unknown[] | — | Адрес всей пагинированной коллекции. Обязательное поле. |
queryFn | ({ pageParam, signal }) => Promise<AxiosResponse<TPage>> | — | Загружает одну страницу. Обязательное поле. |
initialPageParam | unknown | undefined | Параметр первой страницы. |
getNextPageParam | (lastPage, pages) => unknown | — | Возвращает параметр следующей страницы; undefined завершает пагинацию. |
getPreviousPageParam | (firstPage, pages) => unknown | — | Возвращает параметр предыдущей страницы. |
maxPages | number | без лимита | Максимальное число страниц в памяти. |
autoStart | boolean | true у factory | Автоматически вызвать start(). |
enabled | () => boolean | true | Реактивно разрешает или запрещает запрос. |
clearOnDisabled | boolean | true | Очистить локальный snapshot при disabled. |
keepPreviousData | boolean | false | Сохранить snapshot при смене ключа. |
staleTime | number | client default | Время свежести коллекции. |
gcTime | number | client default | Время хранения неактивной коллекции. |
retry | boolean | number | function | client default | Политика повторов. |
retryDelay | number | function | client default | Задержка повторов. |
requestGroup | string | общий пул | Выбирает именованную группу лимита конкурентности runtime. |
networkMode | "online" | "always" | "online" | Политика offline-запуска. |
dedup | boolean | true | Объединение одновременных запросов одного ключа. |
definitionId | string | — | Стабильный id shared definition; проверяется runtime и cache. |
structuralSharing | boolean | true | Переиспользование неизменившихся частей данных. |
meta | { persist?: boolean; broadcast?: boolean } & Record<string, unknown> | — | Opt-in metadata для плагинов. |
displayName | string | "InfiniteQueryStore" | Имя для MobX diagnostics. |
client | QueryClient | изолированный client | Общий cache client; обычно назначается runtime. |
При dedup: false одновременные загрузки одной страницы выполняются как независимые HTTP-запросы; cache изменяет только результат последнего запуска. В networkMode: "online" offline-загрузка получает fetchStatus: "paused", ожидает восстановление сети и затем автоматически продолжается.
При каждом изменении динамического ключа runtime повторно проверяет требование definitionId. Store передаёт id в observer/cache, поэтому несовместимые id одного физического ключа завершаются QueryDefinitionConflictError.
Состояние и методы
| Член | Что означает |
|---|---|
data | { pages: TPage[]; pageParams: unknown[] } | undefined |
pages, pageParams | Удобные getters массивов из data. |
hasNextPage, hasPreviousPage | Можно ли загрузить соседнюю страницу. |
isFetchingNextPage, isFetchingPreviousPage | Выполняется ли загрузка соседней страницы. |
loading, isFetching, status, fetchStatus | Состояние основного query. |
fetch() | Загрузить начальное состояние с учётом cache. |
fetchNextPage() | Загрузить следующую страницу. |
fetchPreviousPage() | Загрузить предыдущую страницу. |
refetch() | Принудительно обновить infinite query. |
invalidate() | Пометить коллекцию устаревшей. |
start(), stop(), dispose() | Управлять observer lifecycle. |
resetLocal() | Очистить snapshot store. |
resetQuery() / removeQuery() / reset() | Удалить коллекцию из cache. |
Опции fetch: force?: boolean и cancelRefetch?: boolean. Опция invalidate: refetchActive?: boolean. Опция dispose: cancel?: boolean только для store с собственным client.