Skip to content

Быстрый старт

На этой странице нет 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.3
sh
pnpm add @dubium/query-layer axios mobx@^7.0.3
sh
yarn add @dubium/query-layer axios mobx@^7.0.3
sh
bun add @dubium/query-layer axios mobx@^7.0.3

MobX 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()
    }
}

Что здесь произошло:

  1. getMyProfileApi описывает endpoint.
  2. createRequestStore создаёт технический MobX handler.
  3. ProfileStore скрывает handler и раскрывает предметный API.
  4. fetchProfile() запускает запрос.
  5. 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.

Следующий шаг