Тема
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-функцию чтения. |
options | FetchStore options | нет | Управляет свежестью, retry и поведением одного запроса. |
client | QueryClient | нет | Общий кэш. Обычно назначается QueryRuntime; без него store владеет изолированным client. |
Рабочие поля options в query mode
FetchStore использует общий IExecuteOptions, но query execution потребляет только перечисленные ниже поля.
| Option | Type | Что делает |
|---|---|---|
staleTime | number | Сколько миллисекунд данные считаются свежими. |
gcTime | number | Сколько хранить неактивную запись кэша. |
force | boolean | Игнорировать свежесть и выполнить запрос. refetch() всегда устанавливает force: true. |
dedup | boolean | Объединять одновременные запросы с одинаковым ключом. |
definitionId | string | Стабильный id query definition; передаётся в QueryClient.fetchQuery. |
networkMode | "online" | "always" | Политика запуска при отсутствии сети. |
retry | boolean | number | (failureCount, error) => boolean | Количество или правило повторов. |
retryDelay | number | (failureCount, error) => number | Задержка между повторами. |
requestGroup | string | Выбирает именованную группу лимита конкурентности runtime для каждой HTTP-попытки. |
cancelPrevious | boolean | При true административно отменяет текущий query этого точного ключа перед новым запуском. |
keepPreviousData | boolean | Не очищать прежние data во время нового запроса. |
isLoading | boolean | Управлять initial loading низкоуровневого handler. |
structuralSharing | boolean | Переиспользовать неизменившиеся части cached data. |
meta | Record<string, unknown> | Метаданные query, включая opt-in persistence/sync. |
onSuccess | (data, response, variables, context) => void | Callback успешного чтения. |
onError | (error, variables, context) => void | Callback ошибки. |
onSettled | (data, error, variables, context) => void | Callback любого завершения. |
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 | Что означает |
|---|---|---|
loading | boolean | Идёт первичная загрузка. |
status | THandlerStatus | null | Статус последнего ручного запроса. |
data | TData | undefined | Данные последнего успеха. |
response | AxiosResponse<TData> | null | Полный Axios response. |
error | INormalizedAxiosError | null | Нормализованная ошибка. |
hasData | boolean | Есть успешные данные. |
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.