Skip to content

FetchStore

FetchStore выполняет кэшируемый запрос чтения только тогда, когда код явно вызывает fetch(...). Обычно это GET. Допустим read-only POST, если endpoint ничего не создаёт и не изменяет на сервере.

WARNING

Создание: new FetchStore(...). Публичной функции createFetchStore нет.

Когда он нужен

Используйте FetchStore, когда:

  • запрос должен начаться по нажатию кнопки или после явного события;
  • результат запроса можно считать server state и сохранять в общем кэше запросов;
  • повторный вызов с тем же queryKey может использовать свежие данные;
  • автоматическая реакция на MobX-параметры не нужна.

Если запрос должен запускаться сразу и повторяться при изменении observable параметров, используйте QueryStore.

API-функция

ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"

import { authApi } from "../../../api"

// Модель данных, которую возвращает backend.
export interface Project {
    id: string
    name: string
    description: string
}

// id участвует в URL, а config получает AbortSignal от FetchStore.
export const getProjectByIdApi = (id: string, config?: AxiosRequestConfig): Promise<AxiosResponse<Project>> => {
    return authApi.get<Project>(`/api/v1/projects/${id}`, config)
}

Domain store

ts
import { makeAutoObservable } from "mobx"

import { FetchStore, type INormalizedAxiosError } from "@dubium/query-layer"
import type { THandlerStatus } from "@dubium/query-layer/contracts"

import { getProjectByIdApi, type Project } from "../api/project-details.api"

export class ProjectDetailsStore {
    // Второй generic — tuple аргументов метода fetch.
    // [string] означает, что fetch ожидает один string: projectId.
    private readonly requestHandler = new FetchStore<Project, [string]>({
        // Ключ строится из тех же аргументов, что переданы в fetch(projectId).
        queryKey: (projectId) => ["projects", "details", projectId],

        // request получает projectId и Axios config с signal.
        request: (projectId, config) => getProjectByIdApi(projectId, config),

        options: {
            // Данные считаются свежими одну минуту.
            staleTime: 60_000,

            // Один ошибочный GET можно безопасно повторить.
            retry: 1,
        },
    })

    constructor() {
        makeAutoObservable<this, "requestHandler">(
            this,
            {
                // FetchStore уже управляет своей MobX-реактивностью.
                requestHandler: false,
            },
            {
                // Привязывает публичные методы к текущему экземпляру.
                autoBind: true,
            },
        )
    }

    // Первичная загрузка без сохранённых данных.
    get loading(): boolean {
        return this.requestHandler.loading
    }

    // Текущий статус ручного запроса.
    get status(): THandlerStatus | null {
        return this.requestHandler.status
    }

    // Нормализованная Axios-ошибка.
    get error(): INormalizedAxiosError | null {
        return this.requestHandler.error
    }

    // UI получает предметное имя project вместо технического data.
    get project(): Project | null {
        return this.requestHandler.data ?? null
    }

    async fetch(projectId: string): Promise<Project | null> {
        // projectId приходит из route params или props компонента.
        // Он попадёт одновременно в queryKey(...) и request(...).
        const response = await this.requestHandler.fetch(projectId)

        // AxiosResponse хранит полезные данные в поле data.
        return response?.data ?? null
    }

    async refresh(projectId: string): Promise<Project | null> {
        // refetch игнорирует staleTime и принудительно идёт в сеть.
        const response = await this.requestHandler.refetch(projectId)
        return response?.data ?? null
    }

    cancel(): void {
        this.requestHandler.cancel()
    }

    reset(): void {
        this.requestHandler.reset()
    }

    dispose(): void {
        this.requestHandler.dispose()
    }
}

Что означает queryKey

ts
;["projects", "details", projectId]

Это адрес записи в query cache:

  • "projects" — область данных;
  • "details" — тип данных внутри области;
  • projectId — конкретная сущность.

Для projectId === "42" ключ будет ["projects", "details", "42"]. Другой id создаст другую запись кэша. Одинаковый ключ должен всегда означать одинаковые данные. Все параметры, которые меняют ответ backend, должны входить в ключ.

Полное объяснение, правила и пересекающиеся ключи: QueryKey и кэш.

Откуда берётся аргумент projectId

ts
import { useEffect, useState } from "react"
import { observer } from "mobx-react-lite"

import { ProjectDetailsStore } from "../../data/projects/details/model/project-details.store"

interface ProjectDetailsProps {
    // Обычно id приходит из router params.
    projectId: string
}

export const ProjectDetails = observer((props: ProjectDetailsProps) => {
    // Store создаётся один раз для этого экземпляра компонента.
    const [store] = useState(() => new ProjectDetailsStore())

    useEffect(() => {
        // Запрос запускается вручную при mount и при смене projectId.
        void store.fetch(props.projectId)

        // При unmount освобождаем ресурсы.
        return () => store.dispose()
    }, [props.projectId, store])

    if (store.loading) {
        return <p>Загружаем проект…</p>
    }

    if (store.error) {
        return (
            <section>
                <p role="alert">{store.error.message}</p>
                <button onClick={() => void store.refresh(props.projectId)}>Повторить</button>
            </section>
        )
    }

    if (!store.project) {
        return <p>Проект не найден</p>
    }

    return (
        <article>
            <h1>{store.project.name}</h1>
            <p>{store.project.description}</p>
        </article>
    )
})

Конфигурация FetchStore

ПолеTypeОбязательноеЧто делает
queryKey(...args: TArgs) => readonly unknown[]даСтроит ключ кэша из аргументов fetch.
request(...args, config?) => Promise<AxiosResponse<TData>>даВыполняет API-функцию чтения.
optionsFetchStore optionsнетУправляет свежестью, retry и поведением одного запроса.
clientQueryClientнетОбщий кэш. Обычно назначается QueryRuntime; без него store владеет изолированным client.

Рабочие поля options в query mode

FetchStore использует общий IExecuteOptions, но query execution потребляет только перечисленные ниже поля.

OptionTypeЧто делает
staleTimenumberСколько миллисекунд данные считаются свежими.
gcTimenumberСколько хранить неактивную запись кэша.
forcebooleanИгнорировать свежесть и выполнить запрос. refetch() всегда устанавливает force: true.
dedupbooleanОбъединять одновременные запросы с одинаковым ключом.
definitionIdstringСтабильный id query definition; передаётся в QueryClient.fetchQuery.
networkMode"online" | "always"Политика запуска при отсутствии сети.
retryboolean | number | (failureCount, error) => booleanКоличество или правило повторов.
retryDelaynumber | (failureCount, error) => numberЗадержка между повторами.
requestGroupstringВыбирает именованную группу лимита конкурентности runtime для каждой HTTP-попытки.
cancelPreviousbooleanПри true административно отменяет текущий query этого точного ключа перед новым запуском.
keepPreviousDatabooleanНе очищать прежние data во время нового запроса.
isLoadingbooleanУправлять initial loading низкоуровневого handler.
structuralSharingbooleanПереиспользовать неизменившиеся части cached data.
metaRecord<string, unknown>Метаданные query, включая opt-in persistence/sync.
onSuccess(data, response, variables, context) => voidCallback успешного чтения.
onError(error, variables, context) => voidCallback ошибки.
onSettled(data, error, variables, context) => voidCallback любого завершения.
onCallbackError(error, callbackName) => void | Promise<void>Получает исключение lifecycle callback, не заменяя исходный result/error.

Повторные вызовы fetch() с одинаковыми аргументами используют одну стабильную definition identity Store и не создают ложный QueryDefinitionConflictError. При dedup: false одновременные вызовы одного ключа выполняются независимо.

FetchStore не записывает аргументы fetch(...args) в поле variables, поэтому variables и context в lifecycle callbacks текущей реализации равны undefined.

mode и queryKey скрыты из options, потому что store задаёт их сам. signal перезаписывается внутренним AbortSignal.

Mutation-only поля (invalidateQueries, onMutate, mutationKey, operationContext) query-ветка текущей реализации не использует, даже если общий TypeScript-тип позволяет их передать.

Состояние и методы

ЧленTypeЧто означает
loadingbooleanИдёт первичная загрузка.
statusTHandlerStatus | nullСтатус последнего ручного запроса.
dataTData | undefinedДанные последнего успеха.
responseAxiosResponse<TData> | nullПолный Axios response.
errorINormalizedAxiosError | nullНормализованная ошибка.
hasDatabooleanЕсть успешные данные.
fetch(...args)Promise<AxiosResponse<TData> | null>Получить данные с учётом свежести кэша.
refetch(...args)Promise<AxiosResponse<TData> | null>Принудительно обновить данные.
fetchWithConfig(args, config)Promise<AxiosResponse<TData> | null>Добавить Axios config конкретного запуска.
cancel()voidОтменить активный вызов этого store.
reset()voidОчистить локальное состояние handler.
dispose()voidОсвободить handler и принадлежащий ему client.

Важное ограничение кэша

Если вы создаёте FetchStore без QueryRuntime, он использует собственный изолированный QueryClient. Кэш полезен для повторных вызовов этого экземпляра, но не разделяется автоматически с другим экземпляром. Для общего кэша между feature или remote используйте QueryRuntime.