Тема
QueryStore
QueryStore — автоматическое кэшируемое чтение. После запуска lifecycle он:
- вычисляет
queryKey(); - проверяет
enabled(); - подписывается на запись query cache;
- вызывает
queryFn({ signal }), если данных нет или они устарели; - повторяет цикл, когда observable-значения внутри
queryKeyилиenabledизменяются.
Создавайте его через createQueryStore(...). Factory по умолчанию сразу вызывает start().
Отличие от FetchStore
| Вопрос | FetchStore | QueryStore |
|---|---|---|
| Кто запускает запрос | Ваш код вызывает fetch(args) | Store запускает его автоматически |
| Где находятся аргументы | В параметрах fetch | В closure функций queryKey и queryFn |
| Реакция на MobX-поля | Нет | Да |
| Типичный кейс | Загрузка по кнопке | Данные экрана зависят от observable id/filters |
API-функция
ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
import { authApi } from "../../../api"
export interface User {
id: string
name: string
email: string
}
// API-функция принимает id пользователя и Axios config.
export const getUserByIdApi = (userId: string, config?: AxiosRequestConfig): Promise<AxiosResponse<User>> => {
return authApi.get<User>(`/api/v1/users/${userId}`, config)
}Domain store с реактивным id
ts
import { makeAutoObservable } from "mobx"
import { createQueryStore, type INormalizedAxiosError, type QueryStore } from "@dubium/query-layer"
import { getUserByIdApi, type User } from "../api/user-details.api"
export class UserDetailsStore {
// Это observable-параметр экрана.
// При его изменении должен измениться и queryKey.
userId: string
// QueryStore создаём в constructor с autoStart: false.
// Так внешние поля успеют стать observable до запуска reaction.
private readonly requestHandler: QueryStore<User>
constructor(initialUserId: string) {
this.userId = initialUserId
this.requestHandler = createQueryStore<User>({
// Lifecycle запустим вручную после makeAutoObservable.
autoStart: false,
// Новый userId создаёт новый адрес в query cache.
queryKey: () => ["users", "details", this.userId],
// Пустой id временно запрещает запрос.
enabled: () => this.userId.length > 0,
// queryFn вызывается без values.
// Нужный id читается из observable-поля store.
queryFn: ({ signal }) => {
return getUserByIdApi(this.userId, { signal })
},
// Одинаковый пользователь считается свежим одну минуту.
staleTime: 60_000,
// Полезное имя для диагностики.
displayName: "UserDetailsQuery",
})
makeAutoObservable<this, "requestHandler">(
this,
{
// Вложенный QueryStore уже observable.
requestHandler: false,
},
{
autoBind: true,
},
)
// Теперь queryKey() действительно отслеживает observable userId.
this.requestHandler.start()
}
// Компонент меняет id через предметный метод.
setUserId(userId: string): void {
this.userId = userId
}
get user(): User | null {
return this.requestHandler.data ?? null
}
get loading(): boolean {
return this.requestHandler.loading
}
// isFetching также true при фоновом обновлении старых данных.
get isFetching(): boolean {
return this.requestHandler.isFetching
}
get error(): INormalizedAxiosError | null {
return this.requestHandler.error
}
async refresh(): Promise<User | null> {
// refetch принудительно обновляет текущий queryKey.
const response = await this.requestHandler.refetch()
return response?.data ?? null
}
dispose(): void {
// Останавливает reaction и отсоединяет observer.
this.requestHandler.dispose()
}
}Почему fetch(values) здесь нет
У QueryStore нет аргумента values: это автоматический store. Все данные, которые определяют запрос, находятся в замыкании:
ts
queryKey: () => ["users", "details", this.userId]
queryFn: ({ signal }) => getUserByIdApi(this.userId, { signal })При setUserId("42") MobX замечает изменение this.userId. queryKey() возвращает новый ключ, QueryStore переключает observer и загружает пользователя 42. При возврате к ранее загруженному id store сначала проверит его cache.
Важно: параметр, влияющий на queryFn, должен влиять и на queryKey. Иначе разные ответы попадут в одну запись кэша.
Подробнее: QueryKey и кэш.
Использование в React
ts
import { useEffect, useState } from "react"
import { observer } from "mobx-react-lite"
import { UserDetailsStore } from "../../data/users/details/model/user-details.store"
interface UserDetailsProps {
userId: string
}
export const UserDetails = observer((props: UserDetailsProps) => {
// Store создаётся один раз с первоначальным id.
const [store] = useState(() => new UserDetailsStore(props.userId))
useEffect(() => {
// Prop изменился — обновляем observable id.
// QueryStore сам запустит запрос для нового queryKey.
store.setUserId(props.userId)
}, [props.userId, store])
useEffect(() => {
// Cleanup выполняется только при unmount.
return () => store.dispose()
}, [store])
if (store.loading) {
return <p>Загружаем пользователя…</p>
}
if (store.error && !store.user) {
return <p role="alert">{store.error.message}</p>
}
if (!store.user) {
return <p>Пользователь не найден</p>
}
return (
<article>
<h1>{store.user.name}</h1>
<p>{store.user.email}</p>
<button disabled={store.isFetching} onClick={() => void store.refresh()}>
{store.isFetching ? "Обновляем…" : "Обновить"}
</button>
</article>
)
})Конфигурация QueryStore
| Поле | Type | Default | Что делает |
|---|---|---|---|
queryKey | () => readonly unknown[] | — | Реактивно вычисляет адрес данных в cache. Обязательное поле. |
queryFn | ({ signal }) => Promise<AxiosResponse<TData>> | — | Выполняет API-функцию. Обязательное поле. |
autoStart | boolean | true у factory | Автоматически вызвать start(). Конструктор new QueryStore сам I/O не запускает. |
enabled | () => boolean | true | Разрешает запрос. Может читать observable-поля. |
clearOnDisabled | boolean | true | Очистить локальный snapshot, когда enabled() стал false. Cache при этом не удаляется. |
staleTime | number | настройки client | Время свежести данных в миллисекундах. |
gcTime | number | настройки client | Время хранения query после исчезновения последнего observer. |
retry | boolean | number | function | настройки client | Политика повторов при ошибке. |
retryDelay | number | function | настройки client | Задержка между повторами. |
requestGroup | string | общий пул | Выбирает именованную группу лимита конкурентности runtime. |
networkMode | "online" | "always" | "online" | Поведение при отсутствии сети. |
dedup | boolean | true | Объединять одновременные запросы одного ключа. |
structuralSharing | boolean | true | Сохранять ссылки на неизменившиеся части данных. |
keepPreviousData | boolean | false | Политика сохранения локального snapshot при смене ключа. |
definitionId | string | — | Стабильный id shared definition; проверяется runtime и cache. |
meta | { persist?: boolean; broadcast?: boolean } & Record<string, unknown> | — | Opt-in метаданные плагинов и диагностики. |
displayName | string | "QueryStore" | Имя MobX store в diagnostics. |
client | QueryClient | изолированный client | Внешний общий кэш. Обычно передаётся через QueryRuntime/scope. |
dedup: false запускает независимые одновременные HTTP-запросы одного ключа; cache применяет результат последнего запуска. Query считается stale точно на границе elapsed === staleTime, поэтому staleTime: 0 не создаёт окно свежести в одну миллисекунду. Для active focus/reconnect filtering используется минимальный staleTime активных observers общего Query.
При каждом изменении динамического ключа runtime повторно проверяет требование definitionId. Store передаёт id в observer/cache, поэтому несовместимые id одного физического ключа завершаются QueryDefinitionConflictError.
Опции методов
| Метод | Option | Type | Что делает |
|---|---|---|---|
fetch | force | boolean | Игнорировать fresh state. |
fetch | cancelRefetch | boolean | Административно отменить текущий shared request перед новым. Используйте осторожно. |
refetch | cancelRefetch | boolean | Та же политика отмены; force уже включён. |
invalidate | refetchActive | boolean | Сразу перезапросить активный query после invalidation. |
dispose | cancel | boolean | Отменить запрос только если store владеет собственным client. |
Состояние и методы
| Член | Что означает |
|---|---|
data, response, error | Последние данные, Axios response и ошибка. |
status | idle, loading, success или error. |
fetchStatus | idle, fetching или paused. |
loading | Первичная загрузка без cached data. |
isFetching | Любой сетевой запрос, включая background refetch. |
isStale | Данные отсутствуют, invalidated или старше staleTime. |
queryKey, queryHashKey | Текущий структурный ключ и его hash. |
start(), stop() | Включить или остановить reaction/observer lifecycle. |
fetch(), refetch() | Получить данные или принудительно обновить их. |
invalidate() | Пометить текущую запись устаревшей. |
cancel() | Отсоединить ожидание этого consumer, не ломая других consumers. |
resetLocal() | Очистить только snapshot текущего store. |
resetQuery() / removeQuery() / reset() | Удалить shared query и очистить snapshot. |
dispose() | Остановить lifecycle и освободить observer. |
Простой статический query
Если queryKey не зависит от внешнего MobX-поля, можно использовать короткую инициализацию прямо в поле класса:
ts
private readonly requestHandler = createQueryStore({
queryKey: () => ["profile", "me"],
queryFn: ({ signal }) => getMyProfileApi({ signal }),
})В таком случае отдельные autoStart: false и start() не нужны.