Тема
Быстрый старт
На этой странице нет runtime-валидации и Valibot*. Сначала разберём базовый путь данных.
Полный production flow с DTO, schema и mapper находится на странице Flow разработчика.
Valibot не является зависимостью
@dubium/query-layerи используется в документации только для демонстрации контракта данных.
Требования
@dubium/query-layer не зависит от UI-фреймворка.
Библиотека работает на уровне data/server state и использует:
MobX — для observable-состояния stores; Axios — как HTTP-транспорт.
Вы можете использовать @dubium/query-layer с React, Vue, Angular, Svelte или без UI-фреймворка вообще.
Примеры в этой документации в основном написаны на React, но React не является зависимостью или требованием библиотеки.
Node.js 20или новее;TypeScriptсоstrict: true;Axios 1.6или новее;MobX 7.0.3или новее, но ниже8.0;
sh
npm install @dubium/query-layer axios mobx@^7.0.3sh
pnpm add @dubium/query-layer axios mobx@^7.0.3sh
yarn add @dubium/query-layer axios mobx@^7.0.3sh
bun add @dubium/query-layer axios mobx@^7.0.3MobX 7
@dubium/query-layer использует API MobX 7 и объявляет peer dependency mobx >=7.0.3 <8.0.0. MobX 6 больше не поддерживается. Для React-приложений используйте mobx-react-lite 5.x (React 18+) или совместимый mobx-react 10.x.
Если проект обновляется с MobX 6, прочитайте миграцию на MobX 7 до обновления lockfile.
@dubium/query-layer ничего не знает о UI-слое и не определяет архитектуру приложения.
Способ подключения stores к компонентам и организация domain/application слоя остаются на стороне приложения.
1. Создайте общий Axios-клиент
ts
import { createAxiosClient } from "@dubium/query-layer"
// Один клиент переиспользуется всеми API-функциями приложения.
// baseURL добавляется перед относительным URL каждого endpoint.
export const api = createAxiosClient({
baseURL: "http://localhost:3000",
timeout: 15_000,
withCredentials: true,
})createAxiosClient — transport-инфраструктура. Она не хранит состояние React-экрана и не должна вызываться из компонента. Компонент будет работать только с MobX-store.
2. Напишите API-функции
Название «API-функция» означает одну TypeScript-функцию, которая описывает конкретный backend endpoint: HTTP-метод, URL, параметры и тип ответа.
Ниже две обычные функции чтения: список пользователей и пользователь по id.
ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
import { api } from "../../api"
// Тип одного пользователя в ответе backend.
export interface User {
id: string
name: string
email: string
}
// GET /api/v1/users возвращает весь список.
export const getUsersApi = (config?: AxiosRequestConfig): Promise<AxiosResponse<User[]>> => {
return api.get<User[]>("/api/v1/users", config)
}
// GET /api/v1/users/:id возвращает одного пользователя.
export const getUserByIdApi = (userId: string, config?: AxiosRequestConfig): Promise<AxiosResponse<User>> => {
return api.get<User>(`/api/v1/users/${userId}`, config)
}Почему AxiosRequestConfig всегда стоит последним:
- Query Layer добавляет туда
signalдля отмены запроса; - вызывающий код может добавить
headers,timeoutили другие Axios options; - предметные аргументы (
userId, DTO, filters) остаются перед config.
API-функция не хранит loading и не вызывает MobX. Это задача store.
3. Зачем оборачивать API-функцию в store
Прямой вызов getMyProfileApi() из React быстро создаёт повторяющийся код:
- локальный
loading; try/catchи приведение ошибки;- защита от устаревшего ответа;
AbortControllerпри unmount;- reset и повторный запрос.
RequestStore решает техническую часть одинаково. Domain store добавляет предметные имена: profile, fetchProfile, clearProfile.
Важно: RequestStore не означает POST. Он означает «прямой запрос без query cache». Поэтому ниже он выполняет GET профиля.
API-функция профиля
ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
import { api } from "../../api"
export interface Profile {
id: string
name: string
email: string
}
// Endpoint возвращает профиль текущей авторизованной сессии.
export const getMyProfileApi = (config?: AxiosRequestConfig): Promise<AxiosResponse<Profile>> => {
return api.get<Profile>("/api/v1/profile/me", config)
}Domain store профиля
ts
import { makeAutoObservable } from "mobx"
import { createRequestStore, type INormalizedAxiosError } from "@dubium/query-layer"
import type { THandlerStatus } from "@dubium/query-layer/contracts"
import { getMyProfileApi, type Profile } from "../api/profile.api"
export class ProfileStore {
// Handler вызывает API и хранит техническое состояние запроса.
// Тип Profile выводится из getMyProfileApi автоматически.
private readonly requestHandler = createRequestStore({
api: getMyProfileApi,
options: {
// Один повтор GET безопасен: запрос не изменяет server state.
retry: 1,
},
})
constructor() {
makeAutoObservable<this, "requestHandler">(
this,
{
// requestHandler уже является MobX-store.
requestHandler: false,
},
{
// Методы можно безопасно передавать в callbacks.
autoBind: true,
},
)
}
// UI видит предметное имя profile, а не техническое data.
get profile(): Profile | null {
return this.requestHandler.data ?? null
}
get loading(): boolean {
return this.requestHandler.loading
}
get status(): THandlerStatus | null {
return this.requestHandler.status
}
get error(): INormalizedAxiosError | null {
return this.requestHandler.error
}
async fetchProfile(): Promise<Profile | null> {
// API-функция не принимает предметных аргументов,
// поэтому execute вызывается без values.
const response = await this.requestHandler.execute()
// Axios хранит полезную нагрузку в response.data.
return response?.data ?? null
}
cancel(): void {
this.requestHandler.cancel()
}
reset(): void {
this.requestHandler.reset()
}
dispose(): void {
this.requestHandler.dispose()
}
}Что здесь произошло:
getMyProfileApiописывает endpoint.createRequestStoreсоздаёт технический MobX handler.ProfileStoreскрывает handler и раскрывает предметный API.fetchProfile()запускает запрос.loading,profileиerrorменяются реактивно.
Полное API: RequestStore.
React-компонент профиля
ts
import { useEffect, useState } from "react"
import { observer } from "mobx-react-lite"
import { ProfileStore } from "@/data/profile/model/profile.store"
export const ProfileCard = observer(() => {
// Функция внутри useState выполняется только при первом render.
const [store] = useState(() => new ProfileStore())
useEffect(() => {
// Запускаем запрос после mount компонента.
void store.fetchProfile()
// При unmount отменяем незавершённый запрос и освобождаем handler.
return () => store.dispose()
}, [store])
if (store.loading) {
return <p>Загружаем профиль…</p>
}
if (store.error) {
return <p role="alert">{store.error.message}</p>
}
if (!store.profile) {
return <p>Профиль пока не загружен</p>
}
return (
<article>
<h1>{store.profile.name}</h1>
<p>{store.profile.email}</p>
</article>
)
})observer следит за теми observable-полями, которые компонент прочитал во время render. Когда loading или profile меняется, компонент перерисовывается.
4. Ручное кэшируемое чтение через FetchStore
RequestStore не сохраняет результат в общем кэше запросов. Если результат GET-запроса нужно кэшировать, но сам запрос должен запускаться вручную, используйте FetchStore.
ts
import { makeAutoObservable } from "mobx"
import { FetchStore } from "@dubium/query-layer"
import { getUserByIdApi, type User } from "../api/users.api"
export class UserDetailsStore {
private readonly requestHandler = new FetchStore<User, [string]>({
// queryKey — адрес данных в cache.
// id обязателен, потому что каждый пользователь имеет отдельный ответ.
queryKey: (userId) => ["users", "details", userId],
// request — функция, которую store вызовет при fetch(userId).
request: (userId, config) => getUserByIdApi(userId, config),
// options — политика кэша и выполнения.
options: {
// В течение минуты повторный fetch того же id может взять fresh cache.
staleTime: 60_000,
retry: 1,
},
})
constructor() {
makeAutoObservable<this, "requestHandler">(this, { requestHandler: false }, { autoBind: true })
}
get user(): User | null {
return this.requestHandler.data ?? null
}
get loading(): boolean {
return this.requestHandler.loading
}
async fetch(userId: string): Promise<User | null> {
// Один userId идёт и в queryKey, и в request.
const response = await this.requestHandler.fetch(userId)
return response?.data ?? null
}
}Разберём три обязательные части:
| Часть | Простое объяснение |
|---|---|
queryKey | Возвращает уникальный адрес результата в cache. |
request | Вызывает API-функцию с теми же аргументами. |
options | Настраивает свежесть, retry, dedup и другое поведение. |
["users", "details", userId] читается как: область users, тип данных details, конкретный userId. Для 42 и 77 получатся разные записи.
Полные страницы: FetchStore и QueryKey и кэш.
5. Автоматическое чтение через QueryStore
QueryStore нужен, когда чтение должно начаться автоматически. Он следит за queryKey() и enabled() через MobX reaction.
ts
import { makeAutoObservable } from "mobx"
import { createQueryStore } from "@dubium/query-layer"
import { getUsersApi, type User } from "../api/users.api"
export class UsersStore {
private readonly requestHandler = createQueryStore<User[]>({
// Список всех пользователей имеет один стабильный ключ.
queryKey: () => ["users", "list"],
// QueryStore сам вызывает queryFn после start().
queryFn: ({ signal }) => getUsersApi({ signal }),
// Список считается свежим 30 секунд.
staleTime: 30_000,
})
constructor() {
makeAutoObservable<this, "requestHandler">(this, { requestHandler: false }, { autoBind: true })
}
get users(): User[] {
return this.requestHandler.data ?? []
}
get loading(): boolean {
return this.requestHandler.loading
}
async refresh(): Promise<void> {
// refetch принудительно обновит список.
await this.requestHandler.refetch()
}
dispose(): void {
this.requestHandler.dispose()
}
}Здесь нет fetch(values), потому что запрос автоматический. Его зависимости читаются внутри queryKey и queryFn. Для динамического id или filters поля outer store сначала делают observable, затем запускают QueryStore. Полный пример: QueryStore.
6. Когда нужен QueryRuntime
Каждый store может работать отдельно. QueryRuntime добавляйте, когда нужен один общий владелец server state:
- общий cache между несколькими stores;
- общие default options;
- focus/reconnect lifecycle;
- централизованный dispose;
- persistence, sync или Module Federation.
ts
import { createQueryRuntime } from "@dubium/query-layer/runtime"
// Один runtime на приложение.
export const runtime = createQueryRuntime({
mode: "application",
identity: {
applicationId: "portal",
version: "1.0.0",
},
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
},
})
// Вызывается до render React-root.
await runtime.initialize()Это только composition root. Перед созданием participant scope сначала прочитайте полную страницу QueryRuntime: там объяснено, зачем scope нужен, какие factories он даёт и кто отвечает за его dispose().
Не добавляйте runtime «на всякий случай». Для простого локального сценария прямой store понятнее. Подробнее: QueryRuntime.
Следующий шаг
- Production-структура с Valibot, DTO и mapper: Flow разработчика.
- Полные правила ключей: QueryKey и кэш.
- Ошибочные архитектурные решения: Анти-паттерны.