Skip to content

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

ПолеTypeDefaultЧто делает
queryKey() => readonly unknown[]Адрес всей пагинированной коллекции. Обязательное поле.
queryFn({ pageParam, signal }) => Promise<AxiosResponse<TPage>>Загружает одну страницу. Обязательное поле.
initialPageParamunknownundefinedПараметр первой страницы.
getNextPageParam(lastPage, pages) => unknownВозвращает параметр следующей страницы; undefined завершает пагинацию.
getPreviousPageParam(firstPage, pages) => unknownВозвращает параметр предыдущей страницы.
maxPagesnumberбез лимитаМаксимальное число страниц в памяти.
autoStartbooleantrue у factoryАвтоматически вызвать start().
enabled() => booleantrueРеактивно разрешает или запрещает запрос.
clearOnDisabledbooleantrueОчистить локальный snapshot при disabled.
keepPreviousDatabooleanfalseСохранить snapshot при смене ключа.
staleTimenumberclient defaultВремя свежести коллекции.
gcTimenumberclient defaultВремя хранения неактивной коллекции.
retryboolean | number | functionclient defaultПолитика повторов.
retryDelaynumber | functionclient defaultЗадержка повторов.
requestGroupstringобщий пулВыбирает именованную группу лимита конкурентности runtime.
networkMode"online" | "always""online"Политика offline-запуска.
dedupbooleantrueОбъединение одновременных запросов одного ключа.
definitionIdstringСтабильный id shared definition; проверяется runtime и cache.
structuralSharingbooleantrueПереиспользование неизменившихся частей данных.
meta{ persist?: boolean; broadcast?: boolean } & Record<string, unknown>Opt-in metadata для плагинов.
displayNamestring"InfiniteQueryStore"Имя для MobX diagnostics.
clientQueryClientизолированный 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.