Головна / Статті / React-форми, керовані схемою: відображення та перевірка за допомогою JSON Schema

React-форми, керовані схемою: відображення та перевірка за допомогою JSON Schema

Як отримати результати перевірки форм React, сформованих безпосередньо з JSON Schema, працювати з $ref, одинИз та умовними гілками if/then, вбудовувати власні віджети та уникати поширених підводних каменів перевірки.

2345 слів

Ручно створений формуляр у React зазвичай є копією вже існуючого контракту. Схема запиту API визначає, що поле email є обов’язковим та має вигляд електронної адреси, що age — це ненегативне ціле число, а role може мати лише три значення. Повторне введення цих правил у JSX, потім у бібліотеці валідації та ще раз у повідомленнях про помилки створює три окремі джерела інформації щодо однакової структури даних, і вони починають розходитися як тільки змінюється бекенд.

У цьому посібнику формуляр розглядається як похідний від JSON Schema, причому для конкретної реалізації використовується безкоштовний пакет react-simple-schema-form. Ви побачите, як $ref, allOf, oneOf та if/then перетворюються на динамічні поля, як підключати власні компоненти та які механізми валідації роблять генерований формуляр схожим на створений вручну.

Чому схема має керувати формою

Зміни є передбачуваними: нове поле на серверному боці ніколи не потрапляє до форми, а запит на кшталт „показати лише адресу для оплати рахунку“ перетворюється на флаг useState, умовне відображення та гілку перевірки, які через кілька місяців втрачають синхронність.

JSON Schema вже може описати кожне з цих правил: типи, обмеження, обов’язкові поля та умовну логіку. Часто це той самий документ, за яким ваш сервер перевіряє запити та який вбудовується у вашу специфікацію OpenAPI. Якщо форма генерується з нього, зміна схеми одночасно оновлює інтерфейс та його перевірку.

Мінімальна генерована форма

react-simple-schema-form приймає JSON Schema, створене за специфікацією draft-07, та відображає сформовану після перевірки форму. Згідно з його документацією, він не має жодних залежностей під час виконання, окрім React 18, постачає власні типи TypeScript та пропонує необов’язковий стилішит. Його встановлення здійснюється за допомогою одного пакету:

npm install react-simple-schema-form

У наведеному нижче прикладі описано невеликий об’єкт користувача: ім’я, електронна пошта з параметром format: 'email', ціле число віку, мінімум якого дорівнює нулю, та роль, обмежена за допомогою enum. Поля name та email вказані як обов’язкові. Компонент отримує схему та функцію-колбек onSubmit, і нічого більше.

import { SchemaForm } from 'react-simple-schema-form';
import 'react-simple-schema-form/styles.css';

const schema = {
  type: 'object',
  properties: {
    name:  { type: 'string', title: 'Name' },
    email: { type: 'string', format: 'email', title: 'Email' },
    age:   { type: 'integer', minimum: 0, title: 'Age' },
    role:  { type: 'string', enum: ['Admin', 'Editor', 'Viewer'], title: 'Role' },
  },
  required: ['name', 'email'],
};

<SchemaForm schema={schema} onSubmit={(data) => save(data)} />

На основі цієї схеми бібліотека відображає поле для введення тексту, поле для електронної пошти, поле для введення чисел та список для вибору значень з енумерації, позначає обов’язкові поля, відображає помилки безпосередньо у полі введення та викликає onSubmit лише тоді, коли дані є коректними. Цей компонент працює в обох режимах React: можна передати value та onChange для керування ним, або defaultValue, щоб дозволити йому самостійно керувати своїм станом.

Будь-який генератор може обробляти такий плоский об’єкт; справжнім випробуванням є вкладені та розгалужені схеми.

Обробка схем, які не є плоскими

У продакшн-схемах використовуються повторювані визначення, складаються фрагменти та здійснюється розгалуження на основі даних. Бібліотека обробляє все це відносно поточних даних форми перед кожним відображенням, тому кожне поле бачить лише спрощену схему.

Повторне використання визначень за допомогою $ref та allOf

Спільне визначення, таке як address, може бути посилано у двох місцях, і воно буде відображатися як два окремі розділи. Ключові слова, розташовані поруч із $ref, переважають над посиланим визначенням, тому { "$ref": "#/definitions/address", "title": "Shipping address" } створює блок адреси під назвою „Адреса доставки“. За допомогою allOf частини глибоко об’єднуються: вкладені властивості об’єднуються рекурсивно, а масиви required об’єднуються у їхню суму.

Використання oneOf як дискримінованої уніони

Багато генераторів мають проблеми з oneOf. Ефективним підходом є використання дискримінованої уніони: кожна гілка прив’язує спільне поле до фіксованого значення за допомогою const, а форма використовує це поле для вибору активної гілки.

У наведеній нижче схемі оплати method є енумерацією значень card або bank. Перший варіант встановлює method у значення card та вимагає наявності поля number; другий варіант встановлює його у значення bank та вимагає наявності поля iban.

{
  "type": "object",
  "properties": { "method": { "type": "string", "enum": ["card", "bank"] } },
  "required": ["method"],
  "oneOf": [
    { "title": "Card", "properties": { "method": { "const": "card" }, "number": { "type": "string" } }, "required": ["number"] },
    { "title": "Bank", "properties": { "method": { "const": "bank" }, "iban":   { "type": "string" } }, "required": ["iban"] }
  ]
}

Зміна значення method з card на bank призводить до заміни поля номера картки на поле IBAN. У вашому коді немає стану компонента та умовного JSX; зміну здійснює сама схема. Елемент oneOf, відгалуження якого містять лише значення const, відображається як маркований список вибору.

Умовні розділи з використанням if/then/else та dependencies

Умовні ключові слова переоцінюються щоразу, коли змінюються дані, включаючи після кожного натискання клавіші. Корисним рішенням є опційний розділ, який перевіряється лише після того, як користувач його увімкне. Наведений нижче фрагмент визначає об’єкт schedule із булевим прапорцем enabled (за замовчуванням false) та двома полями для днів. Умова if виконується, коли enabled дорівнює true, а умова then робить monday та tuesday обов’язковими у цьому випадку.

"schedule": {
  "type": "object",
  "properties": {
    "enabled": { "type": "boolean", "title": "Enable schedule", "default": false },
    "monday":  { "type": "string", "title": "Monday" },
    "tuesday": { "type": "string", "title": "Tuesday" }
  },
  "if":   { "properties": { "enabled": { "const": true } }, "required": ["enabled"] },
  "then": { "required": ["monday", "tuesday"] }
}

Коли прапорець вимкнений, нічого всередині розділу не є обов’язковим, і надсилання форми не блокується. Коли він увімкнений, обидва поля з датою отримують позначки про обов’язковість, і форму не можна надіслати, доки вони не будуть заповнені. Оскільки прапорець є частиною даних, а не локального стану інтерфейсу, сервер може перевірити той самий набір даних за тією ж схемою та дійти до того самого висновку.

Рядок "required": ["enabled"] всередині if легко можна пропустити, але його необхідно зберегти. У JSON Schema поле properties обмежує лише ті ключі, які є. Тож об’єкт без ключа enabled відповідає формату { "properties": { "enabled": { "const": true } } }, активується відгалуження then, і поля стають обов’язковими, навіть якщо цей розділ ніколи не був увімкнений. Вимога до наявності цього ключа у умові закриває цю прогалину.

Вибір та налаштування віджетів

Сформована форма є практичною лише тоді, коли ви можете контролювати, який елемент введення використовується для кожного поля. Бібліотека містить реєстр вбудованих віджетів, зокрема text, email, number, select, radio, checkboxes, textarea та date, і пропонує три способи їх призначення:

  1. Властивість uiSchema з ключем, визначеним за шляхом, із підтримкою глобальних шаблонів. tags.* вказує на кожен елемент масиву, а **.postalCode — на кожний поштовий індекс на будь-якій глибині, навіть всередині $ref, використаного у двох місцях. Якщо кілька ключів збігаються, перемагає найбільш конкретний з них.
  • Підказки, вбудовані у схему. Вузол може містити власні ключові слова ui:*, а батьківський елемент може мати вкладену структуру uiSchema, адресовану за назвами дочірніх елементів, тож хтось, хто посилається на спільне визначення, може змінити стиль його дочірніх елементів.
  • Функція resolveWidget для вибору на основі правил, наприклад „кожне ціле число з format: epoch використовує відповідний віджет“. Вона отримує повністю розрешену схему та може повернути або назву віджета, або компонент.
  • Порядок пріоритету є фіксованим: uiSchema додатку має вищий пріоритет, ніж підказки, вбудовані у схему, які, у свою чергу, мають вищий пріоритет ніж правила resolveWidget, а ті — вищий ніж значення за замовчуванням. Ця передбачуваність є важливою, коли схему надає інша команда: клієнт завжди може перевизначити її підказки.

    Створення власного віджета

    Віджет — це компонент, який отримує поточне значення та функцію-відповідь onChange, а також властивості на кшталт id, required, disabled та onBlur. У наведеному нижче прикладі часовий маркер зберігається у вигляді секунд за стандартом Unix, але користувачеві відображається вбудований вибраник типу datetime-local. Для відображення секунди перетворюються на рядок з датою, а при зміні значення вхідний даний знову аналізується: мілісекунди діляться на 1000, а якщо поле порожнє чи містить некоректний даний, передається значення undefined. Віджет реєструється під ім’ям epoch та присвоюється полю startsAt через uiSchema.

    import type { Widget } from 'react-simple-schema-form';
    
    const EpochWidget: Widget<number | undefined> = ({ id, value, onChange, onBlur, required, disabled }) => (
      <input
        type="datetime-local"
        id={id}
        required={required}
        disabled={disabled}
        value={value === undefined ? '' : new Date(value * 1000).toISOString().slice(0, 16)}
        onBlur={onBlur}
        onChange={(e) => {
          const ms = new Date(e.target.value).getTime();
          onChange(Number.isNaN(ms) ? undefined : Math.floor(ms / 1000));
        }}
      />
    );
    
    <SchemaForm schema={schema} widgets={{ epoch: EpochWidget }} uiSchema={{ startsAt: { widget: 'epoch' } }} />
    

    У схемі вказано integer, користувач бачить поле вибору, а дані зберігаються у форматі секунд Unix. Є одна умова: toISOString() генерує значення у форматі UTC, тоді як вхідне поле типу datetime-local та оператор new Date(e.target.value) працюють у місцевому часовому поясі користувача. Поза форматом UTC відображений час зміщується на значення зсуву часового поясу, і кожна зміна оновлює збережене значення. Тож краще форматувати відображуване значення на основі місцевих частин дати, щоб обидві системи були узгоджені.

    Віджет також може містити цілий об’єкт або масив, отримуючи весь цей даний разом із усіма вкладеними помилками, та відображати його елементи за допомогою експортованого компонента <Field>. Саме так розділ з розкладом отримує можливість перемикання між видимістю та приховуванням, без необхідності для бібліотеки знати про сам розклад.

    Якщо схема посилається на віджет, який ніколи не реєструвався, бібліотека записує одне попередження та переходить на стандартний варіант введення даних. Опечатка у схемі, наданій іншою командою, повинна спричиняти лише помірні проблеми, а не зупинку роботи сторінки.

    Перевірка, що відповідає очікуванням користувача

    Вбудований валідатор є компактним та не потребує додаткових залежностей; більша частина його конструкції присвятована визначенню моменту повідомлення про помилки, а не самому їх наявності.

    Відображати помилки у правильний момент

    Помилки з’являються після того, як користувач залишає поле, або всі разом після спроби надсилання даних, і ніколи під час першого відображення сторінки. Якщо надсилання даних провалилося, фокус переміщується на перше некоректне поле.

    Вважати недоторкані необов’язкові об’єкти відсутніми

    Щоб відобразити поля введення для вкладених об’єктів, форма заповнює їх за допомогою {}. Наївний валідатор тоді вимагатиме наявності полів street та city для необов’язкової адреси, якою користувач ніколи не користувався. Рішенням є розглядати необов’язковий об’єкт, усі значення якого порожні, як відсутній, щоб уникнути помилок. Обов’язковий об’єкт завжди перевіряється, і замість розмитого повідомлення „Адреса є обов’язковою“ він відображає список помилок щодо відсутніх дочірніх елементів.

    Помилки атрибута oneOf у активній гілці

    Коли жодна з гілок oneOf не підтверджує валідність, загальне повідомлення „Дані мають точно відповідати одній схемі“ є марним для користувача. Натомість валідатор визначає, до якої гілки належать дані, порівнюючи їх за дискримінантами та типами, але ігноруючи параметр required, та повідомляє про помилки на рівні полів цієї гілки. У випадку оплати з методом method: card без номера картки помилка відображається у полі з номером картки, де й буде шукати користувач.

    Ніколи не дозволяйте прихованим полям блокувати надсилання

    Залишені, напівнабрані значення у розділі, який було вимкнено, не повинні призводити до невдачі перевірки pattern, яку користувач не може бачити. Правило знаходиться у схемі, але рішення — у віджеті: він очищує розділ під час його вимкнення, а властивість errors повідомляє віджет про наявні помилки у тій частині, яку він приховує.

    Повторне використання правил поза React

    Валідатор також експортується окремо. Функція validate(schema, data) повертає список елементів типу { path, keyword, message }, тож одні й ті самі правила можна використовувати у сервісі Node.js, під час тестування модулів або перед відображенням будь-чого. Щодо альтернативи, орієнтованої на TypeScript, дивіться способи обміну однією схемою Zod між React та Node.

    Документація, призначена для асистентів з кодування

    Форми часто створюються за допомогою ШІ-асистентів з кодування, тому пакет постачається разом із документацією, призначеною як для машин, так і для людей:

    • Файл з інструкціями щодо Agent Skills за адресою skills/react-simple-schema-form/SKILL.md усередині пакета npm, який інструменти, що підтримують формат Agent Skills, можуть завантажити з node_modules. У ньому описано API, пріоритет віджетів, вищезгадані схеми та поширені проблеми; розмір файлу на момент написання становить приблизно 7 kB.
    • llms.txt та llms-full.txt на демо-сайті, які об’єднують README, описи навичок та всі приклади схем у один файл, який можна вставити у чат або індексувати сервером MCP для документації.
    • JSDoc із прикладами до кожного експорту, тож коли користувач перетягує курсор над оголошеннями типів, відображається пояснення їх використання.
    • Файл context7.json, який дозволяє репозиторію правильно індексуватися в Context7.

    Це не змусить модель обирати бібліотеку, але підвищує ймовірність того, що перша спроба асистента вдасться, і цю практику варто застосовувати також для внутрішніх бібліотек.

    Спробувати

    жива демонстрація розміщує редактор схеми поруч із створеним формою, а під нею — актуальні дані та помилки. У ній є приклади використання $ref, allOf, oneOf, if/then/else, dependencies та вибору віджетів. Пакет опублікований на npm, а вихідний код та трекер проблем знаходяться на GitHub. Це молодий проект, тому перш ніж покладатися на нього, протестуйте його з власними схемами.

    Основні висновки

    • Якщо API вже публікує JSON Schema, створення форми на його основі усуває дубльовані правила та забезпечує синхронність перевірок у користувацькому інтерфейсі та на сервері.
    • Обробляйте елементи $ref, allOf, oneOf та умовні елементи на основі актуальних даних, щоб кожне поле отримувало просту структуру схеми.
    • Форматуйте варіанти моделей як дискриміновані союзи за допомогою const, і завжди додавайте атрибут required у конструкціях if.
    • Забезпечуйте можливість зміни вибору віджетів за допомогою чіткого порядку пріоритетів, особливо для схем, якими керує інша команда.
    • Якісні створені форми залежать від моменту перевірки: повідомляйте про помилки під час виходу з поля або надсилання форми, ігноруйте недоторкані необов’язкові об’єкти та вказуйте помилки типу oneOf на активній гілці.