Как делать11 октября 2026 г.8 мин чтения
ReactШаг 5 из 8

Формы и валидация в React

Коротко

Правила проверки удобнее всего описать одной схемой Zod и использовать её дважды: в форме через React Hook Form и zodResolver и на сервере через safeParse — проверка в браузере экономит время пользователя, а защищает данные только серверная. Ошибку показывайте после ухода из поля (mode: "onTouched"), связывайте с полем через aria-invalid и aria-describedby, а кнопку на время отправки блокируйте по isSubmitting.

РуководствоУровень: СреднийФормы и валидацияПрактика: ~1–2 ч

После материала вы сможете

  • выбрать между управляемым (value + onChange) и неуправляемым (defaultValue + FormData) полем и объяснить цену каждого
  • описать данные формы схемой Zod и проверить ей и форму, и тело запроса на сервере
  • собрать форму на React Hook Form, которая показывает ошибку после ухода из поля и блокирует повторную отправку
  • связать ошибку с полем через aria-invalid и aria-describedby и проверить это тестом Testing Library

Проверено на

React 19.3React Hook Form 7.90Zod 4.6@hookform/resolvers 5.9TypeScript 6.0Vite 8.3Vitest 5.0 + Testing Library 16.3

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Управляемые и неуправляемые поля
  2. 02Нативная валидация HTML и её пределы
  3. 03Схема на Zod: одна правда для клиента и сервера
  4. 04React Hook Form: регистрация полей и момент ошибки
  5. 05Отправка: pending, ошибка сервера, успех
  6. 06Доступность ошибок: aria-invalid и aria-describedby
  7. 07Проверка: тесты и частые ошибки

Управляемые и неуправляемые поля

Значение поля формы может жить в двух местах: в состоянии React или в самом DOM-элементе. Поле с value и onChange называется управляемым (controlled): React на каждом рендере принудительно ставит в него значение из состояния, а onChange это состояние обновляет. Поле без value, в лучшем случае с defaultValue, — неуправляемое (uncontrolled): начальное значение задаёт JSX, дальше значение хранит браузер, а читают его при отправке через FormData или ref.

Документация React фиксирует несколько жёстких правил. Если передать value без onChange, в поле невозможно печатать, а в консоли появится предупреждение. Значение управляемого поля не должно быть undefined или null — начинайте с пустой строки. Поле не может за время жизни переключиться из неуправляемого в управляемое. И главное для производительности: управляемое поле меняет состояние на каждое нажатие клавиши, поэтому при большом дереве компонентов ввод начинает тормозить — состояние стоит опускать в маленький компонент рядом с полем.

Где значение

Управляемое
Состояние React (useState)
Неуправляемое
DOM-элемент, читается при отправке

Пропсы

Управляемое
value + onChange (checked для чекбокса)
Неуправляемое
defaultValue / defaultChecked, обязательно name

Ререндер

Управляемое
На каждое нажатие клавиши
Неуправляемое
Только когда меняется что-то ещё

Когда брать

Управляемое
Интерфейс зависит от значения прямо сейчас: счётчик символов, зависимое поле, поиск по мере ввода
Неуправляемое
Значение нужно только при отправке — большинство полей заявки
src/CommentField.tsx
import { useState } from "react";

const MAX = 200;

/* controlled: the value lives in state because the UI depends on it */
export function CommentField() {
  const [comment, setComment] = useState("");
  const left = MAX - comment.length;

  return (
    <div>
      <label htmlFor="comment">Comment</label>
      <textarea
        id="comment"
        name="comment"
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        maxLength={MAX}
      />
      <p>{left} characters left</p>
    </div>
  );
}
Управляемое поле оправдано: счётчик пересчитывается при каждом вводе

Нативная валидация HTML и её пределы

Браузер умеет проверять поля без единой строки JavaScript. Атрибуты required, minlength, pattern, типы email и url задают ограничения (constraints), а результат проверки лежит в объекте ValidityState (input.validity). Пока хоть одно поле не проходит проверку, браузер не отправит форму: событие submit не наступит, и React-обработчик onSubmit не вызовется, а у первого неверного поля появится системная подсказка.

src/NativeLeadForm.tsx
import type { FormEvent } from "react";

type Props = { onSend: (data: Record<string, FormDataEntryValue>) => void };

/* uncontrolled fields + built-in browser validation */
export function NativeLeadForm({ onSend }: Props) {
  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault(); // the browser calls onSubmit only if every field is valid
    onSend(Object.fromEntries(new FormData(e.currentTarget)));
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="native-name">Name</label>
      <input id="native-name" name="name" required minLength={2} autoComplete="name" />

      <label htmlFor="native-phone">Phone</label>
      <input
        id="native-phone"
        name="phone"
        type="tel"
        required
        pattern="[+0-9 \(\)\-]{10,18}"
        autoComplete="tel"
      />

      <button type="submit">Send</button>
    </form>
  );
}
Неуправляемые поля, проверка браузером и чтение данных через FormData

Для простой формы этого может хватить. Но у нативной проверки есть пределы, из-за которых в приложениях её обычно дополняют своей:

  • Текст и вид системных сообщений зависят от браузера, и стилизовать их нельзя — MDN прямо пишет об этом на странице aria-invalid.
  • Подсказку браузер показывает у одного поля за раз, а не список всех ошибок.
  • Часть ограничений, например minlength, проверяется только для введённого пользователем значения, а не для заданного из JavaScript.
  • Нормализовать данные (привести телефон «8 700…» к «+7700…») атрибутами нельзя.
  • Проверку в браузере легко обойти, поэтому MDN подчёркивает: она не отменяет проверку на сервере. А правила на сервере придётся описать ещё раз — если у вас нет общей схемы.

Схема на Zod: одна правда для клиента и сервера

Схема (schema) — это описание формы данных в коде: какие поля, какого типа, с какими ограничениями и какими текстами ошибок. Zod — библиотека для TypeScript, которая по схеме и проверяет значение, и выводит из неё статический тип. Метод parse возвращает проверенную копию данных или бросает ZodError, safeParse вместо исключения возвращает объект { success, data | error }. Текст ошибки в Zod 4 задаётся параметром error.

src/lead-schema.ts
import * as z from "zod";

export const SERVICES = ["website", "web-app", "crm"] as const;

/* "8 (700) 123-45-67", "+7 700 123 45 67", "7001234567" → "+77001234567" */
export function normalizePhone(raw: string): string {
  const digits = raw.replace(/\D/g, "");
  if (digits.length === 10) return `+7${digits}`;
  if (digits.length === 11 && /^[78]/.test(digits)) return `+7${digits.slice(1)}`;
  return raw.trim();
}

export const leadSchema = z.object({
  name: z
    .string()
    .trim()
    .min(2, { error: "Enter your name" })
    .max(80, { error: "Name is too long" }),
  phone: z
    .string()
    .transform(normalizePhone)
    .pipe(z.string().regex(/^\+7\d{10}$/, { error: "Enter a phone like +7 700 123 45 67" })),
  service: z.string().pipe(z.enum(SERVICES, { error: "Choose a service" })),
  consent: z.boolean().refine((value) => value, { error: "Consent is required to send the form" }),
});

/* what the form holds (input) and what you get after parsing (output) */
export type LeadInput = z.input<typeof leadSchema>;
export type Lead = z.output<typeof leadSchema>;
Схема заявки: имя, телефон в любом формате, услуга и согласие

Три решения в этой схеме стоит разобрать. Телефон принимается в любом привычном виде и через transform приводится к одному формату, а уже потом pipe проверяет результат регуляркой — это ровно тот принцип «нормализовать формат — задача кода», который описан в уроке «Формы без боли». Услуга в форме сначала пустая строка, поэтому вход описан как z.string(), а результат сужается до z.enum. Из-за transform и pipe тип входа и тип результата различаются, и Zod отдаёт оба: z.input — то, что лежит в форме, z.output — то, что получит обработчик отправки.

Та же схема на сервере закрывает дыру, которую не закрывает браузер: запрос можно отправить в обход формы. z.flattenError превращает ошибку в плоский объект «поле → сообщения», который удобно вернуть в ответе API:

src/server-validate.ts
import * as z from "zod";
import { leadSchema } from "./lead-schema.ts";

/* the same schema on the server: the browser can be bypassed, the server cannot */
export function parseLead(body: unknown) {
  const result = leadSchema.safeParse(body);
  if (!result.success) {
    return { ok: false as const, fieldErrors: z.flattenError(result.error).fieldErrors };
  }
  return { ok: true as const, lead: result.data };
}
Проверка тела запроса на сервере той же схемой
src/server-check.ts
import { parseLead } from "./server-validate.ts";

console.log(parseLead({ name: "  Aigerim ", phone: "8 (700) 123-45-67", service: "crm", consent: true }));
console.log(parseLead({ name: "A", phone: "123", service: "", consent: false }));
terminal
$ node src/server-check.ts
{
  ok: true,
  lead: {
    name: 'Aigerim',
    phone: '+77001234567',
    service: 'crm',
    consent: true
  }
}
{
  ok: false,
  fieldErrors: {
    name: [ 'Enter your name' ],
    phone: [ 'Enter a phone like +7 700 123 45 67' ],
    service: [ 'Choose a service' ],
    consent: [ 'Consent is required to send the form' ]
  }
}
Реальный вывод Node.js 24: телефон нормализован, имя обрезано, ошибки разложены по полям

React Hook Form: регистрация полей и момент ошибки

React Hook Form (RHF) строится на неуправляемых полях: функция register("name") возвращает name, ref, onChange и onBlur, которые разворачиваются в <input>. Значения хранятся в DOM и во внутреннем хранилище библиотеки, а компонент перерисовывается только когда меняется то, на что он подписан, например список ошибок. Схему подключают через resolver из пакета @hookform/resolvers. Документация RHF предупреждает: вместе с resolver встроенные правила вроде register("x", { required: true }) не работают — все правила живут в схеме.

bash
npm install react-hook-form zod @hookform/resolvers

Момент проверки задаёт опция mode, а повторную проверку после первой отправки — reValidateMode (по умолчанию onChange):

onSubmit (по умолчанию)

Когда проверяет
При отправке, затем поля с ошибками — при изменении
Что видит пользователь
Ошибки только после нажатия кнопки

onBlur

Когда проверяет
При уходе из поля
Что видит пользователь
Ошибка появляется после ухода, но и исчезает только после нового ухода

onChange

Когда проверяет
На каждое изменение
Что видит пользователь
Ругань с первого символа; документация RHF предупреждает о заметной цене для производительности

onTouched

Когда проверяет
Первый раз — при уходе из поля, дальше — на каждое изменение
Что видит пользователь
Пока печатает — тишина; после ошибки она исчезает сразу, как только поле исправлено

all

Когда проверяет
И при уходе, и при изменении
Что видит пользователь
Как onChange, плюс проверка при уходе

Для формы заявки берём onTouched: он совпадает с рекомендацией из урока «Формы без боли» — не ругать, пока человек печатает, проверять после ухода из поля и убирать ошибку сразу после исправления.

src/LeadForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { leadSchema, type Lead, type LeadInput } from "./lead-schema.ts";

type Props = { submitLead: (lead: Lead) => Promise<void> };

const describedBy = (...ids: (string | false)[]) => ids.filter(Boolean).join(" ") || undefined;

export function LeadForm({ submitLead }: Props) {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<LeadInput, unknown, Lead>({
    resolver: zodResolver(leadSchema),
    mode: "onTouched", // first check on blur, then on every change
    defaultValues: { name: "", phone: "", service: "", consent: false },
  });

  async function onSubmit(lead: Lead) {
    try {
      await submitLead(lead);
    } catch {
      setError("root.serverError", {
        message: "Could not send the request. Try again or message us on WhatsApp.",
      });
    }
  }

  if (isSubmitSuccessful) {
    return <p role="status">Thank you! We will call you back within a working day.</p>;
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="lead-name">Name</label>
      <input
        id="lead-name"
        autoComplete="name"
        aria-invalid={errors.name ? true : undefined}
        aria-describedby={describedBy(!!errors.name && "lead-name-error")}
        {...register("name")}
      />
      {errors.name && <p id="lead-name-error">{errors.name.message}</p>}

      <label htmlFor="lead-phone">Phone</label>
      <input
        id="lead-phone"
        type="tel"
        autoComplete="tel"
        aria-invalid={errors.phone ? true : undefined}
        aria-describedby={describedBy("lead-phone-hint", !!errors.phone && "lead-phone-error")}
        {...register("phone")}
      />
      <p id="lead-phone-hint">Any format: 8 700…, +7 700…</p>
      {errors.phone && <p id="lead-phone-error">{errors.phone.message}</p>}

      <label htmlFor="lead-service">Service</label>
      <select
        id="lead-service"
        aria-invalid={errors.service ? true : undefined}
        aria-describedby={describedBy(!!errors.service && "lead-service-error")}
        {...register("service")}
      >
        <option value="">Choose…</option>
        <option value="website">Website</option>
        <option value="web-app">Web application</option>
        <option value="crm">CRM</option>
      </select>
      {errors.service && <p id="lead-service-error">{errors.service.message}</p>}

      <input
        id="lead-consent"
        type="checkbox"
        aria-invalid={errors.consent ? true : undefined}
        aria-describedby={describedBy(!!errors.consent && "lead-consent-error")}
        {...register("consent")}
      />
      <label htmlFor="lead-consent">I agree to the processing of my data</label>
      {errors.consent && <p id="lead-consent-error">{errors.consent.message}</p>}

      {errors.root?.serverError && <p role="alert">{errors.root.serverError.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Sending…" : "Get a quote"}
      </button>
    </form>
  );
}
Форма заявки: схема, момент показа ошибки, доступные ошибки и состояние отправки
  • useForm<LeadInput, unknown, Lead> — третий параметр типа описывает данные после схемы, поэтому onSubmit получает уже нормализованный телефон.
  • defaultValues заданы для всех полей: документация RHF советует не использовать undefined в значениях по умолчанию — это конфликтует с управляемыми полями.
  • noValidate отключает системные подсказки браузера: ошибки показывает сама форма, единообразно и с нужным текстом.

Отправка: pending, ошибка сервера, успех

  1. Проверка

    handleSubmit прогоняет данные через схему. Есть ошибки — onSubmit не вызывается, а фокус по умолчанию переходит на первое неверное поле (shouldFocusError: true).

  2. Ожидание

    Пока промис из onSubmit не завершился, formState.isSubmitting равен true: кнопка заблокирована и говорит «Отправляем…». Повторный клик ничего не отправит — это проверяет тест с двойным кликом.

  3. Ошибка сервера

    Сетевой сбой или ответ не 2xx ловим в catch и записываем через setError("root.serverError"). Такая ошибка не привязана к полю и, по документации RHF, не сохраняется между отправками — повторная попытка начинается с чистого листа.

  4. Успех

    Если обработчик завершился без ошибок, isSubmitSuccessful становится true, и вместо формы появляется сообщение с role="status".

Сама отправка вынесена в проп submitLead. Компонент не знает, куда уходят данные, — в тестах вместо сети подставляется заглушка, а в приложении — обычный fetch, который превращает ответ не 2xx в исключение:

src/App.tsx
import { LeadForm } from "./LeadForm.tsx";
import type { Lead } from "./lead-schema.ts";

async function submitLead(lead: Lead) {
  const res = await fetch("/api/leads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(lead),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
}

export default function App() {
  return <LeadForm submitLead={submitLead} />;
}
Адрес /api/leads — пример; на сервере тело запроса проверяет parseLead из предыдущего раздела

Доступность ошибок: aria-invalid и aria-describedby

Зрячий пользователь видит красную рамку и текст под полем. Скринридеру этого мало: ему нужно знать, что поле неверно, и какой текст к нему относится. Для этого в форме три механизма, и все они уже есть в LeadForm.tsx:

Состояние ошибки

Как в коде
aria-invalid="true" только после проверки
Зачем
Скринридер объявляет поле как неверное. MDN: не ставить на пустое обязательное поле до попытки отправки

Текст ошибки и подсказка

Как в коде
aria-describedby с id подсказки и id ошибки
Зачем
Связь поля с сообщением, как в учебнике W3C WAI; текст читается при фокусе на поле

Фокус после отправки

Как в коде
shouldFocusError в RHF (включён по умолчанию)
Зачем
W3C WAI советует переводить фокус на первое поле с ошибкой

Ошибка без поля

Как в коде
role="alert" у сообщения сервера
Зачем
Объявляется сразу, хотя фокус остаётся на кнопке

Успех

Как в коде
role="status"
Зачем
Вежливое объявление, что заявка ушла

Хелпер describedBy собирает aria-describedby только из существующих id: ссылка на элемент ошибки появляется вместе с самим элементом. У телефона в описании сразу два id — постоянная подсказка о формате и ошибка, когда она есть. MDN описывает и отдельный атрибут aria-errormessage; в примере использован aria-describedby, как в учебнике W3C WAI.

Проверка: тесты и частые ошибки

Тесты на Testing Library работают так, как работает пользователь: находят поле по подписи, печатают, уходят клавишей Tab, нажимают кнопку. getByLabelText найдёт поле, только если <label> связан с ним, а toHaveAccessibleDescription проверяет, что aria-describedby действительно указывает на текст ошибки. Поэтому тест заодно проверяет доступность.

src/LeadForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LeadForm } from "./LeadForm.tsx";

async function fillValid(user: ReturnType<typeof userEvent.setup>) {
  await user.type(screen.getByLabelText("Name"), "Aigerim");
  await user.type(screen.getByLabelText("Phone"), "8 (700) 123-45-67");
  await user.selectOptions(screen.getByLabelText("Service"), "crm");
  await user.click(screen.getByLabelText(/I agree/));
}

describe("LeadForm", () => {
  it("does not complain while typing, shows the error on blur", async () => {
    const user = userEvent.setup();
    render(<LeadForm submitLead={vi.fn()} />);
    const name = screen.getByLabelText("Name");

    await user.type(name, "A");
    expect(screen.queryByText("Enter your name")).not.toBeInTheDocument();

    await user.tab();
    expect(await screen.findByText("Enter your name")).toBeInTheDocument();
    expect(name).toHaveAttribute("aria-invalid", "true");
    expect(name).toHaveAccessibleDescription("Enter your name");

    await user.type(name, "igerim"); // after the first blur it re-validates on change
    expect(screen.queryByText("Enter your name")).not.toBeInTheDocument();
    expect(name).not.toHaveAttribute("aria-invalid");
  });

  it("on empty submit shows every error and focuses the first invalid field", async () => {
    const user = userEvent.setup();
    const submitLead = vi.fn();
    render(<LeadForm submitLead={submitLead} />);

    await user.click(screen.getByRole("button", { name: "Get a quote" }));

    expect(await screen.findByText("Choose a service")).toBeInTheDocument();
    expect(screen.getByText("Consent is required to send the form")).toBeInTheDocument();
    expect(screen.getByLabelText("Name")).toHaveFocus();
    expect(screen.getByLabelText("Phone")).toHaveAccessibleDescription(
      "Any format: 8 700…, +7 700… Enter a phone like +7 700 123 45 67",
    );
    expect(submitLead).not.toHaveBeenCalled();
  });

  it("sends normalized data once and blocks the button while pending", async () => {
    const user = userEvent.setup();
    let finish!: () => void;
    const submitLead = vi.fn(() => new Promise<void>((resolve) => (finish = resolve)));
    render(<LeadForm submitLead={submitLead} />);

    await fillValid(user);
    const button = screen.getByRole("button", { name: "Get a quote" });
    await user.dblClick(button);

    expect(await screen.findByRole("button", { name: "Sending…" })).toBeDisabled();
    expect(submitLead).toHaveBeenCalledTimes(1);
    expect(submitLead).toHaveBeenCalledWith({
      name: "Aigerim",
      phone: "+77001234567",
      service: "crm",
      consent: true,
    });

    finish();
    expect(await screen.findByRole("status")).toHaveTextContent("Thank you!");
  });

  it("shows a server error and lets the user retry", async () => {
    const user = userEvent.setup();
    const submitLead = vi.fn().mockRejectedValueOnce(new Error("HTTP 500")).mockResolvedValueOnce(undefined);
    render(<LeadForm submitLead={submitLead} />);

    await fillValid(user);
    await user.click(screen.getByRole("button", { name: "Get a quote" }));
    expect(await screen.findByRole("alert")).toHaveTextContent("Could not send the request");

    await user.click(screen.getByRole("button", { name: "Get a quote" }));
    expect(await screen.findByRole("status")).toBeInTheDocument();
    expect(submitLead).toHaveBeenCalledTimes(2);
  });
});
Четыре сценария: ошибка при уходе из поля, пустая отправка, двойной клик и ошибка сервера
terminal
$ npx vitest run src/LeadForm.test.tsx --reporter=verbose
 ✓ src/LeadForm.test.tsx > LeadForm > does not complain while typing, shows the error on blur
 ✓ src/LeadForm.test.tsx > LeadForm > on empty submit shows every error and focuses the first invalid field
 ✓ src/LeadForm.test.tsx > LeadForm > sends normalized data once and blocks the button while pending
 ✓ src/LeadForm.test.tsx > LeadForm > shows a server error and lets the user retry

 Test Files  1 passed (1)
      Tests  4 passed (4)
Реальный вывод Vitest (время опущено); проект на шаблоне Vite react-ts, окружение jsdom. Также проверены нативная форма, счётчик и серверная схема — всего 8 тестов

value без onChange

Симптом
В поле нельзя печатать, предупреждение в консоли
Исправление
Добавить onChange или перейти на defaultValue

Начальное значение undefined

Симптом
Предупреждение о переходе из неуправляемого в управляемое
Исправление
Пустая строка в useState("") и defaultValues

pattern без экранирования под флаг v

Симптом
Ограничение молча не работает
Исправление
Экранировать (, ), - и другие символы внутри класса

register("x", { required: true }) вместе с resolver

Симптом
Правило игнорируется
Исправление
Перенести правило в схему

mode: "onChange" для всей формы

Симптом
Ошибка на первом символе, лишние ререндеры
Исправление
onTouched или onBlur

Проверка только в браузере

Симптом
Неверные данные приходят через прямой запрос
Исправление
safeParse той же схемы на сервере
Как проверено5 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    src/CommentField.tsx

    tsc -b + vite build + vitest (1 тест: счётчик после ввода), React 19.3.0, TypeScript 6.0.3, Vite 8.3.4, Vitest 5.0.3 + @testing-library/react 16.3.3, jsdom 30.1.2

  • запущено, работает

    src/NativeLeadForm.tsx

    vitest (2 теста): отправка блокируется при пустых required (jsdom); pattern компилируется с флагом v. Первый вариант pattern с неэкранированными скобками тест поймал — исправлено. Системные сообщения реальных браузеров не проверялись (jsdom)

  • запущено, работает

    src/lead-schema.ts, src/server-validate.ts, src/server-check.ts

    node --no-warnings src/server-check.ts (Node.js 24.18, type stripping) — вывод в статье реальный; vitest (1 тест) на отклонение данных в обход формы; Zod 4.6.6

  • запущено, работает

    src/LeadForm.tsx + src/App.tsx

    tsc -b + vite build + vitest (4 теста, user-event 14.6.7): ошибка при blur и снятие при исправлении, aria-invalid и toHaveAccessibleDescription, фокус на первом поле, двойной клик → один вызов (контрольно: без disabled тест падает), root.serverError + повтор. React Hook Form 7.90.0, @hookform/resolvers 5.9.1. fetch в App.tsx не вызывался против реального API — только сборка

  • запущено, работает

    Упражнения (extend схемы; mode onSubmit; LeadRejectedError + setError по полю)

    решение apply — vitest (1 тест: сообщение у поля, aria-invalid, фокус); extend — vitest; modify — смена mode действительно роняет первый тест

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.