Skip to content

Flow разработчика

Эта страница показывает production-структуру feature: React, MobX, Axios, Valibot, DTO, schema, mapper и Query Layer. Пример — регистрация пользователя.

Почему код разделён

СлойЗнаетНе знает
UIПоля формы и domain storeURL, Axios client, response DTO
Domain storeForm type, mapper, API-функция, состояние запросаJSX и DOM
MapperКак form превращается в request DTOAxios и MobX
API-функцияEndpoint, DTO, schema ответаReact и состояние экрана
TransportbaseURL, credentials, auth refreshКонкретную feature

Так изменение backend DTO не заставляет переписывать React-компонент.

Структура файлов

text
src/
  data/
    api.ts
    auth/
      registration/
        api/
          registration.api.ts
        dto/
          registration-request.dto.ts
          registration-response.dto.ts
        mapper/
          registration.mapper.ts
        model/
          registration.model.ts
          registration.store.ts
        schema/
          registration-form.schema.ts
          registration-response.schema.ts
        index.ts
  features/
    registration/
      ui/
        RegistrationForm.tsx
  shared/
    hooks/
      use-store-instance.ts

Установка Valibot

bash
npm install valibot

Valibot проверяет данные во время выполнения. TypeScript проверяет только код во время сборки и не может гарантировать, что backend действительно прислал объект ожидаемой формы.

1. Transport client

ts
import { createAxiosClient } from "@dubium/query-layer"

// publicApi используется endpoint-ами без access token.
export const publicApi = createAxiosClient({
    baseURL: "http://localhost:3000",
    timeout: 15_000,
    withCredentials: true,
})

Этот файл создаётся один раз. Не создавайте отдельный Axios client для каждого endpoint.

2. Schema и type формы

ts
import { boolean, email, minLength, object, pipe, string, type InferInput } from "valibot"

// Schema описывает значения, которыми управляет форма.
export const RegistrationFormSchema = object({
    firstName: pipe(string(), minLength(1, "Введите имя")),
    email: pipe(string(), email("Введите корректный email")),
    password: pipe(string(), minLength(8, "Минимум восемь символов")),
    acceptedPersonalData: boolean(),
})

// Type выводится из schema, чтобы не описывать форму дважды.
export type RegistrationForm = InferInput<typeof RegistrationFormSchema>

Form model использует понятные UI-имена. Backend DTO может называться иначе.

3. Request DTO

ts

// Это точный контракт body, который ждёт backend.
export interface RegistrationRequestDto {
    first_name: string
    email: string
    password: string
    checked_personal_data: boolean
}

Разделение form и DTO оправдано даже при похожих полях: UI может хранить confirmPassword, чекбоксы и временные значения, которые backend не нужны.

4. Response DTO и Valibot schema

ts
export interface RegistrationUserDto {
    id: string
    firstName: string
    email: string
}

export interface RegistrationSuccessResponseDto {
    data: {
        accessToken: string
        user: RegistrationUserDto
    }
}

export interface RegistrationErrorResponseDto {
    code: string
    message: string
    errors?: Array<{
        field: string
        message: string
    }>
}
ts
import { array, object, optional, string } from "valibot"

// Schema успешного ответа проверяет реальный JSON backend.
export const RegistrationSuccessResponseSchema = object({
    data: object({
        accessToken: string(),
        user: object({
            id: string(),
            firstName: string(),
            email: string(),
        }),
    }),
})

// Error schema используется безопасно: ошибка может иметь неизвестную форму.
export const RegistrationErrorResponseSchema = object({
    code: string(),
    message: string(),
    errors: optional(
        array(
            object({
                field: string(),
                message: string(),
            }),
        ),
    ),
})

DTO даёт TypeScript type. Schema проверяет runtime-данные. Наличие только DTO не защищает от сломанного или изменившегося ответа backend.

5. Mapper формы в DTO

ts
import type { RegistrationRequestDto } from "../dto/registration-request.dto"
import type { RegistrationForm } from "../schema/registration-form.schema"

// Mapper — чистая функция: без HTTP, MobX и side effects.
export const mapRegistrationFormToRequestDto = (form: RegistrationForm): RegistrationRequestDto => {
    return {
        first_name: form.firstName.trim(),
        email: form.email.trim().toLowerCase(),
        password: form.password,
        checked_personal_data: form.acceptedPersonalData,
    }
}

Mapper — единственное место, которое знает различия firstName и first_name.

6. API-функция

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

import { publicApi } from "../../../api"
import type { RegistrationRequestDto } from "../dto/registration-request.dto"
import type { RegistrationSuccessResponseDto } from "../dto/registration-response.dto"
import { RegistrationSuccessResponseSchema } from "../schema/registration-response.schema"

export const registrationApi = async (
    dto: RegistrationRequestDto,
    config?: AxiosRequestConfig,
): Promise<AxiosResponse<RegistrationSuccessResponseDto>> => {
    // response.data пока unknown: доверять внешнему JSON нельзя.
    const response = await publicApi.post<unknown>("/api/v1/auth/registration", dto, config)

    // parse либо возвращает проверенные данные, либо бросает ValibotError.
    const data: RegistrationSuccessResponseDto = parse(RegistrationSuccessResponseSchema, response.data)

    // Сохраняем status, headers и другие поля AxiosResponse,
    // но заменяем data на проверенное значение.
    return {
        ...response,
        data,
    }
}

API-функция является последней границей недоверенных backend-данных. После неё store получает типизированный и проверенный ответ.

7. Domain models

ts

// UI-модель пользователя не обязана повторять весь response DTO.
export interface RegistrationUser {
    id: string
    firstName: string
    email: string
}

// UI получает предсказуемую предметную ошибку.
export interface RegistrationError {
    code: string
    message: string
    fields: Array<{
        field: string
        message: string
    }>
}

8. Domain store

ts
import { makeAutoObservable } from "mobx"
import { safeParse } from "valibot"

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

import { registrationApi } from "../api/registration.api"
import { mapRegistrationFormToRequestDto } from "../mapper/registration.mapper"
import { RegistrationErrorResponseSchema } from "../schema/registration-response.schema"
import type { RegistrationForm } from "../schema/registration-form.schema"
import type { RegistrationError, RegistrationUser } from "./registration.model"

export class RegistrationStore {
    // Начальные значения принадлежат feature, а не React-компоненту.
    readonly defaultValues: RegistrationForm = {
        firstName: "",
        email: "",
        password: "",
        acceptedPersonalData: false,
    }

    // RequestStore отвечает за lifecycle одного submit-запроса.
    private readonly requestHandler = createRequestStore({
        api: registrationApi,
        options: {
            // Registration изменяет server state и может быть неидемпотентной.
            retry: false,
        },
    })

    constructor() {
        makeAutoObservable<this, "requestHandler">(
            this,
            {
                // Не преобразуем вложенный MobX-store повторно.
                requestHandler: false,
            },
            {
                autoBind: true,
            },
        )
    }

    get loading(): boolean {
        return this.requestHandler.loading
    }

    get status(): THandlerStatus | null {
        return this.requestHandler.status
    }

    // Техническая ошибка остаётся доступна для логов и общих UI-состояний.
    get requestError(): INormalizedAxiosError | null {
        return this.requestHandler.error
    }

    // Не доверяем payload ошибки: он пришёл извне и имеет тип unknown.
    get error(): RegistrationError | null {
        const result = safeParse(RegistrationErrorResponseSchema, this.requestHandler.error?.payload)

        if (!result.success) {
            return null
        }

        return {
            code: result.output.code,
            message: result.output.message,
            fields: result.output.errors ?? [],
        }
    }

    get user(): RegistrationUser | null {
        const user = this.requestHandler.data?.data.user

        if (!user) {
            return null
        }

        return {
            id: user.id,
            firstName: user.firstName,
            email: user.email,
        }
    }

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

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

    async fetch(values: RegistrationForm): Promise<RegistrationUser | null> {
        // values пришли из React-формы.
        // Mapper превращает UI-модель в backend DTO.
        const dto = mapRegistrationFormToRequestDto(values)

        // execute вызывает registrationApi(dto, { signal }).
        const response = await this.requestHandler.execute(dto)

        if (response === null) {
            return null
        }

        // Здесь можно сохранить token в отдельный TokenStore.
        // tokenStore.setEncodedToken(response.data.data.accessToken)

        return response.data.data.user
    }

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

Почему fetch(values) получает форму, а не DTO: React работает с понятиями UI, а store владеет преобразованием в backend contract. Если DTO поменяется, компонент останется прежним.

9. useStoreInstance

Следующий hook не входит в npm-пакет @dubium/query-layer. Это маленький проектный helper, который можно положить в src/shared/hooks.

ts
import { useState } from "react"

/**
 * Создаёт один экземпляр store на всё время жизни React-компонента.
 *
 * useState получает функцию-инициализатор. React вызывает её только при
 * первом mount, поэтому MobX-store не пересоздаётся при каждом render.
 *
 * @typeParam TStore - Тип создаваемого store.
 * @param initializer - Функция, которая создаёт store.
 * @returns Стабильный экземпляр store.
 */
export const useStoreInstance = <TStore>(initializer: () => TStore): TStore => {
    const [store] = useState(initializer)

    return store
}

Hook не вызывает dispose(), потому что разные stores могут иметь разный lifecycle. Cleanup остаётся явным в useEffect компонента.

10. React-компонент

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

import { RegistrationStore } from "../../../data/auth/registration/model/registration.store"
import type { RegistrationForm as RegistrationFormValues } from "../../../data/auth/registration/schema/registration-form.schema"
import { useStoreInstance } from "../../../shared/hooks/use-store-instance"

export const RegistrationForm = observer(() => {
    // Hook создаёт store один раз и возвращает тот же объект при rerender.
    const store = useStoreInstance(() => new RegistrationStore())

    // Для простоты примера форма управляется одним объектом useState.
    const [values, setValues] = useState<RegistrationFormValues>(store.defaultValues)

    useEffect(() => {
        // Cleanup выполняется при unmount компонента.
        return () => store.dispose()
    }, [store])

    return (
        <form
            onSubmit={(event) => {
                event.preventDefault()

                // UI передаёт только form values.
                // DTO, Axios и Valibot response parsing скрыты внутри data-layer.
                void store.fetch(values)
            }}
        >
            <input
                aria-label="Имя"
                value={values.firstName}
                onChange={(event) => {
                    setValues((current) => ({
                        ...current,
                        firstName: event.target.value,
                    }))
                }}
            />

            <input
                aria-label="Email"
                type="email"
                value={values.email}
                onChange={(event) => {
                    setValues((current) => ({
                        ...current,
                        email: event.target.value,
                    }))
                }}
            />

            <input
                aria-label="Пароль"
                type="password"
                value={values.password}
                onChange={(event) => {
                    setValues((current) => ({
                        ...current,
                        password: event.target.value,
                    }))
                }}
            />

            <label>
                <input
                    checked={values.acceptedPersonalData}
                    type="checkbox"
                    onChange={(event) => {
                        setValues((current) => ({
                            ...current,
                            acceptedPersonalData: event.target.checked,
                        }))
                    }}
                />
                Согласен на обработку данных
            </label>

            <button disabled={store.loading} type="submit">
                {store.loading ? "Регистрируем…" : "Зарегистрироваться"}
            </button>

            {store.error && <p role="alert">{store.error.message}</p>}
            {!store.error && store.requestError && <p role="alert">Не удалось выполнить запрос</p>}

            {store.user && <p>Пользователь {store.user.firstName} зарегистрирован</p>}
        </form>
    )
})

Компонент импортирует domain store, form type и локальный hook. Он не импортирует publicApi, registrationApi, request DTO или response schema.

Полная цепочка данных

mermaid
flowchart TD
    Form["React form values"] --> Store["RegistrationStore.fetch"]
    Store --> Mapper["Form → Request DTO"]
    Mapper --> Handler["RequestStore.execute"]
    Handler --> Api["registrationApi"]
    Api --> Backend["POST backend"]
    Backend --> Schema["Valibot parse"]
    Schema --> Handler
    Handler --> Store
    Store --> UI["observer render"]

Что тестировать

ЧастьТип теста
MapperUnit: точное преобразование form → DTO.
SchemaUnit: valid/invalid backend payload.
API-функцияIntegration с mocked Axios adapter/backend.
Domain storeUnit: loading, success, error, cancel, reset.
React-компонентComponent: действия пользователя и отображение state.
Весь flowPlaywright против тестового Express backend.

Перед review проверьте анти-паттерны.