Strona główna / Artykuły / Formularze w React napędzane schematami: renderowanie i walidacja na podstawie JSON Schema

Formularze w React napędzane schematami: renderowanie i walidacja na podstawie JSON Schema

Jak renderować zweryfikowane formularze React bezpośrednio z JSON Schema, obsługiwać $ref, oneOf oraz gałęzie if/then, wdrażać własne elementy interfejsu oraz unikać powszechnych pułapek weryfikacji.

2345 słów

Ręcznie pisany formularz w React zazwyczaj kopiuje już istniejącą umowę. Schemat żądania API wskazuje, że email jest obowiązkowy i musi wyglądać jak adres e-mail, że age to nieujemna liczba całkowita, a role może przyjmować jedną z trzech wartości. Ponowne wpisywanie tych reguł w JSX, a następnie w bibliotece do walidacji i ponownie w komunikatach o błędach tworzy trzy źródła prawdy dla tego samego kształtu danych, które rozchodzą się w momencie zmiany backendu.

W tym przewodniku formularz jest traktowany jako pochodzący z JSON Schema, przy czym jako konkretna implementacja używany jest otwarty pakiet react-simple-schema-form. Zobaczysz, jak $ref, allOf, oneOf oraz if/then przekształcają się w dynamiczne pola, jak dodawać własne elementy interfejsu oraz które zachowania walidacji sprawiają, że generowany formularz wygląda na ręcznie stworzony.

Dlaczego schemat powinien zarządzać formularzem

Zmiany są przewidywalne: nowe pole w backendzie nigdy nie trafia do formularza, a żądanie typu „pokaż adres fakturowania tylko dla płatności rachunkiem” zamienia się w flagę useState, warunkowe renderowanie oraz gałąź walidacji, które po kilku miesiącach tracą synchronizację.

JSON Schema może już wyrazić każdą z tych zasad: typy, ograniczenia, pola obowiązkowe oraz logikę warunkową. Często jest to ten sam dokument, którym backend weryfikuje żądania i który jest włączany do specyfikacji OpenAPI. Jeśli formularz jest generowany na jego podstawie, zmiana schematu aktualizuje zarówno interfejs użytkownika, jak i jego mechanizmy walidacji jednocześnie.

Minimalny generowany formularz

react-simple-schema-form przyjmuje JSON Schema napisane zgodnie z draft-07 i renderuje weryfikowany formularz. Zgodnie z dokumentacją nie ma żadnych zależności w czasie wykonywania poza React 18, dostarcza własne typy TypeScript oraz oferuje opcjonalny plik stylów. Instalacja polega na dodaniu jednego pakietu:

npm install react-simple-schema-form

Poniższy przykład opisuje mały obiekt użytkownika: imię, adres e-mail z ustawieniem format: 'email', całkowitą liczbę wieku nie mniejszą od zera oraz rolę określoną za pomocą enum. Pola name i email są uznane za obowiązkowe. Komponent otrzymuje schemat oraz funkcję zwrotną onSubmit, i nic więcej.

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)} />

Zgodnie z tym schematem biblioteka wyświetla pole tekstowe, pole na adres e-mail, pole numeryczne oraz listę wyboru dla typu enum, zaznacza pola obowiązkowe, pokazuje błędy w miejscu ich wystąpienia i wywołuje onSubmit dopiero wtedy, gdy dane są poprawne. Komponent funkcjonuje w obu trybach React: można mu przekazać value i onChange, aby nim sterować, lub defaultValue, aby pozwolić mu zarządzać własnym stanem.

Każdy generator radzi sobie z takim prostym obiektem; prawdziwym wyzwaniem są schematy zagnieżdżone i rozgałęzione.

Zarządzanie schematami, które nie są proste

Schematy używane w produkcji ponawiają użycie definicji, łączą fragmenty oraz rozgałęziają się w zależności od danych. Biblioteka rozwiązuje to wszystko na podstawie aktualnych danych formy przed każdym wyświetleniem, dzięki czemu każde pole widzi tylko uproszczone schemat.

Ponawne wykorzystywanie definicji za pomocą $ref i allOf

Definicja wspólna, taka jak address, może być odwoływana w dwóch miejscach i będzie wyświetlana jako dwa niezależne sekcje. Słowa kluczowe umieszczone obok $ref zastępują odwoływaną definicję, więc { "$ref": "#/definitions/address", "title": "Shipping address" } tworzy blok adresu oznaczony jako „Adres wysyłki”. Z użyciem allOf części są łączone w sposób głęboki: właściwości nawiasowe są łączone rekurencyjnie, a tablice required są łączone w ich zbiór.

Traktowanie oneOf jako zjednoczenia dyskryminowanego

Wiele generatorów ma trudności z oneOf. Skutecznym rozwiązaniem jest zjednoczenie dyskryminowane: każda gałąź przypisuje wspólne pole do stałej wartości za pomocą const, a formularz wykorzystuje to pole do wyboru aktywnej gałęzi.

W poniższym schemacie płatności method to wartość z typu enum, która może przyjmować wartości card lub bank. Pierwsza gałąź ustawia method na card i wymaga pola number; druga ustawia je na bank i wymaga pola 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"] }
  ]
}

Zmiana wartości method z card na bank powoduje zamianę pola z numerem karty na pole IBAN. W twoim kodzie nie ma stanu komponentu ani warunkowego JSX – zmianę determinuje wyłącznie sam schemat. Element oneOf, którego gałęzie zawierają jedynie wartość const, jest renderowany jako wybór z etykietą.

Części warunkowe przy użyciu if/then/else i dependencies

Słowa kluczowe warunkowe są ponownie oceniane za każdym razem, gdy zmieniają się dane, włącznie przy każdym naciśnięciu klawisza. Praktycznym rozwiązaniem jest sekcja opcjonalna, która jest weryfikowana tylko po jej włączeniu przez użytkownika. Poniższy fragment definiuje obiekt schedule z flagą booleanową enabled (domyślnie false) oraz dwoma polami z dniami tygodnia. Warunek if jest spełniony, gdy enabled ma wartość true, a warunek then sprawia, że w takim przypadku pola monday i tuesday stają się obowiązkowe.

"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"] }
}

Gdy przełącznik jest wyłączony, nie ma żadnych wymagań dotyczących treści w tym sekcji i wysłanie formularza nie jest blokowane. Gdy jest włączony, oba pola z datą otrzymują znaczniki wymaganych danych, a formularz nie może zostać wysłany, dopóki nie zostaną one wypełnione. Ponieważ przełącznik stanowi część danych, a nie lokalnego stanu interfejsu, serwer może zweryfikować ten sam zestaw danych przy użyciu tego samego schematu i dojść do tej samej decyzji.

Linię "required": ["enabled"] znajdującą się wewnątrz if łatwo pominąć, ale konieczne jest jej zachowanie. W JSON Schema atrybut properties ogranicza jedynie te klucze, które są obecne. Obiekt bez klucza enabled spełnia więc warunki { "properties": { "enabled": { "const": true } } }, wtedy aktywuje się gałąź then, a pola stają się obowiązkowe, mimo że sekcja nigdy nie została włączona. Wymaganie obecności tego klucza w warunku eliminuje ten problem.

Wybieranie i personalizowanie elementów interfejsu

Formularz wygenerowany jest praktyczny tylko wtedy, gdy można kontrolować, który element wejściowy jest używany przez każde pole. Biblioteka przechowuje rejestr wbudowanych elementów interfejsu, w tym text, email, number, select, radio, checkboxes, textarea oraz date, i oferuje trzy sposoby ich przypisywania:

  1. Właściwość uiSchema oparta na ścieżce, z obsługą wzorów globowych. tags.* odnosi się do każdego elementu w tablicy, a **.postalCode odnosi się do każdego kodu pocztowego na dowolnej głębokości, nawet wewnątrz $ref użytego w dwóch miejscach. Gdy kilka kluczy pasuje, decyduje ten najbardziej specyficzny.
  • Sugestie wplecione w schemat. Węzeł może zawierać własne słowa kluczowe ui:*, a element nadrzędny może przechowywać zagnieżdżony uiSchema adresowany za pomocą nazw potomków, dzięki czemu ktokolwiek odwołuje się do wspólnej definicji może zmienić styl jej potomków.
  • Funkcja resolveWidget służąca do podejmowania decyzji opartych na regułach, np. „każda liczba całkowita z format: epoch używa widgeta epoch”. Otrzymuje w pełni rozwiązany schemat i może zwrócić albo nazwę widgeta, albo komponent.
  • Kolejność priorytetów jest ustalona: uiSchema aplikacji ma pierwszeństwo przed sugestiami wplecionymi w schemat, które z kolei mają pierwszeństwo przed regułami resolveWidget, a te z kolei przed ustawieniami domyślnymi. Ta przewidywalność jest ważna, gdy schemat dostarcza inny zespół – klient może zawsze przejąć kontrolę nad swoimi sugestiami.

    Pisanie własnego widgeta

    Widget to komponent, który otrzymuje aktualną wartość oraz funkcję zwrotną onChange, a także właściwości takie jak id, required, disabled i onBlur. Poniższy przykład przechowuje datę i godzinę w postaci sekund Unixa, ale pokazuje użytkownikowi wbudowany wybieracz typu datetime-local. Konwertuje sekundy na ciąg znaków reprezentujący datę w celu wyświetlenia, a po zmianie ponownie parsuje wprowadzoną wartość, dzieląc milisekundy przez 1000 oraz przekazując undefined, gdy pole jest puste lub nieprawidłowe. Widget jest zarejestrowany pod nazwą epoch i przypisywany do pola startsAt za pośrednictwem 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' } }} />
    

    Schemat wskazuje integer, użytkownik widzi pole wyboru, a dane przechowują sekundy Unixa. Jeden warunek: toISOString() zwraca wartości w formacie UTC, podczas gdy pole typu datetime-local oraz new Date(e.target.value) funkcjonują zgodnie z lokalnym strefą czasową użytkownika. Poza UTC wyświetlana data jest przesunięta o różnicę stref, a każda modyfikacja zmienia przechowywaną wartość. Należy zatem formatować wyświetlaną wartość na podstawie lokalnych części daty, aby obie strony były zgodne.

    Widget może również przechowywać cały obiekt lub tablicę, otrzymując całą wartość wraz ze wszystkimi zagnieżdżonymi błędami, a następnie renderować jej elementy za pomocą eksportowanego komponentu <Field>. W ten sposób sekcja harmonogramu uzyskuje funkcjonalność włączania i wyłączania bez konieczności, by biblioteka wiedziała o istnieniu harmonogramów.

    Jeśli schemat odnosi się do widgetu, który nigdy nie został zarejestrowany, biblioteka zapisuje jedno ostrzeżenie i używa domyślnego pola wejściowego. Błąd pisowni w schemacie dostarczanym przez inną zespół powinien powodować łagodne zachowanie aplikacji, a nie awarię strony.

    Budowany wewnętrzny walidator jest mały i nie wymaga żadnych zależności, a większość jego projektu skupia się na tym, kiedy zgłaszać błędy, a nie tylko na tym, czy one w ogóle istnieją.

    Błędy pojawiają się po opuszczeniu przez użytkownika pola lub wszystkie naraz po próbie wysłania danych, a nigdy przy pierwszym renderowaniu. Gdy wysyłka zawodzi, uwaga przenosi się na pierwsze nieprawidłowe pole.

    Aby wyświetlić pola wprowadzania danych dla zagnieżdżonych obiektów, formularz wypełnia je za pomocą {}. Prosty walidator wymagałby wtedy informacji o street i city dla adresu opcjonalnego, którego użytkownik w ogóle nie edytował. Rozwiązaniem jest traktowanie obiektu opcjonalnego, którego wszystkie wartości są puste, jako nieobecnego, dzięki czemu nie powstają żadne błędy. Obiekt wymagany jest zawsze walidowany, a jego lista błędów wskazuje, które elementy brakuje, zamiast ogólnikowego komunikatu „Adres jest wymagany”.

    Błędy atrybutu oneOf w aktywnej gałęzi

    Gdy żadna z gałęzi oneOf nie jest prawidłowa, ogólne komunikaty typu „dane muszą dokładnie pasować do jednej szablony” są bezużyteczne dla użytkownika. Zamiast tego walidator określa, do której gałęzi należą dane, porównując je pod kątem wskaźników i typów, ignorując jednak atrybut required, oraz raportuje błędy na poziomie pól tej gałęzi. W przypadku płatności z ustawieniem method: card bez numeru karty, błąd pojawia się w polu z numerem karty, tam gdzie użytkownik będzie szukał informacji.

    Nigdy nie pozwólaj ukrytym polom blokować wysyłki

    Pozostałe, częściowo wpisane wartości w sekcji, która została wyłączona, nie powinny powodować błędu w sprawdzeniu pattern, którego użytkownik nie może zobaczyć. Zasada ta znajduje się w szablonie, ale rozwiązanie leży w widgetzie: ten czyszczy sekcję po jej wyłączeniu, a właściwość errors informuje widget o błędach istniejących w ukrytej części.

    Używaj tych zasad poza Reactem

    Walidator jest również eksportowany osobno. Funkcja validate(schema, data) zwraca listę wpisów typu { path, keyword, message }, dzięki czemu te same zasady mogą być stosowane w usłudze Node.js, w testach jednostkowych lub przed wyświetleniem jakiejkolwiek treści. Aby zapoznać się z alternatywą opartą na TypeScript, sprawdź dzielone schemat Zod pomiędzy React a Node.

    Dokumentacja skierowana do asystentów kodowania

    Często formy są tworzone przy użyciu asystenta kodowania opartego na sztucznej inteligencji, dlatego pakiet zawiera dokumentację przeznaczoną zarówno dla maszyn, jak i ludzi:

    • Plik z informacjami o umiejętnościach agenta znajdujący się pod adresem skills/react-simple-schema-form/SKILL.md w pakiecie npm, który narzędzia obsługujące format Agent Skills mogą załadować z katalogu node_modules. Zawiera opis API, zasady priorytetu widgetów, powyższe przykłady implementacji oraz znane problemy; jego rozmiar w momencie pisania tekstu wynosi około 7 kB.
    • Pliki llms.txt i llms-full.txt na stronie demonstracyjnej, które łączą plik README, informacje o umiejętnościach oraz każdy przykładowy schemat w jeden plik, który można wkleić do czatu lub indeksować za pomocą serwera MCP do dokumentacji.
    • JSDoc z przykładami dla każdej funkcji eksportowanej, dzięki czemu podświetlanie elementów interfejsu edytora nad deklaracjami typów wyjaśnia sposób użycia.
    • Plik context7.json, który umożliwia czyste indeksowanie repozytorium w narzędziu Context7.

    To nie sprawi, że model wybierze konkretną bibliotekę, ale zwiększa szanse na to, że pierwsza próba asystenta się uda – praktyka ta nadaje się również do naśladowania w przypadku bibliotek wewnętrznych.

    Próba wykorzystania

    żywa demonstracja umieszcza edytor schematów obok utworzonego formularza, z aktualnymi danymi i błędami poniżej. Zawiera przykłady z użyciem $ref, allOf, oneOf, if/then/else, dependencies oraz wyboru elementów interfejsu. Pakiet jest dostępny na npm, a źródło kodu oraz tracker błędów znajdują się na GitHub. Jest to młody projekt, dlatego przed poleganiem na nim przetestuj go ze swoimi własnymi schematami.

    Główne wnioski

    • Jeśli API już publikuje JSON Schema, generowanie formy na jego podstawie eliminuje powtarzające się reguły i zapewnia synchronizację między walidacją w interfejsie a na serwerze.
    • Rozwiązywaj problemy z $ref, allOf, oneOf oraz warunkami na podstawie aktualnych danych, aby każde pole miało spójną strukturę schematu.
    • Modeluj formy z wariantami jako zespoły rozróżnialne za pomocą const, a zawsze dodawaj atrybut required w klauzulach if.
    • Zapewnij możliwość modyfikacji wyboru elementów interfejsu przy zachowaniu jasnego porządku priorytetów, szczególnie w przypadku schematów należących do innej grupy.
    • Dobre generowane formy zależą od momentu wykonywania walidacji: raportuj błędy przy zmianie pola lub wysłaniu formy, ignoruj niezmienione obiekty opcjonalne oraz wskazuj błędy typu oneOf na aktywną gałąź.

    Literatura pokrewna

  • Utrzymaj Hooks bez głowy: oddzielanie zachowania alerty od jej renderowania — Dowiedz się, dlaczego hook w React, który zwraca JSX, ukrywa część drzewa komponentów i łączy zachowanie z prezentacją, oraz jak przepisać go tak, aby zamiast tego eksponował stan.
  • Ręcznie stworzone formularze React: kontrolowane pola, walidacja i stany zapisu — Poznaj, jak działają formularze w React – od kontrolowanych pól i obsługiwaczy dla poszczególnych typów po walidację, stany zapisu, dynamiczne pola, przesyłanie plików oraz w jakich sytuacjach biblioteki formularzy są przydatne.
  • Podstawa React gotowa do użycia w produkcji: co faktycznie robi każdy pakiet. — Ustaw Vite, Tailwind v4, Redux Toolkit, React Router, Jest i Prettier dla aplikacji React oraz zrozum, dlaczego każdy pakiet i linia konfiguracji jest tam obecna.