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.
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:
- Właściwość
uiSchemaoparta na ścieżce, z obsługą wzorów globowych.tags.*odnosi się do każdego elementu w tablicy, a**.postalCodeodnosi się do każdego kodu pocztowego na dowolnej głębokości, nawet wewnątrz$refużytego w dwóch miejscach. Gdy kilka kluczy pasuje, decyduje ten najbardziej specyficzny.
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.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.mdw pakiecie npm, który narzędzia obsługujące format Agent Skills mogą załadować z katalogunode_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.txtillms-full.txtna 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,oneOforaz 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 atrybutrequiredw klauzulachif. - 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
oneOfna aktywną gałąź.
Literatura pokrewna
- Dzielenie sobą jednym schema Zod między frontendem React a backendem Node — Dowiedz się, jak pojedyncze schema Zod może weryfikować formularze React, odpowiedzi API, treści zapytań Express oraz zmienne środowiskowe, jednocześnie generując odpowiadające im typy TypeScript.
- Śledzenie wezwania React setState od kolejki update do DOM Commit — Prześledź krok po kroku aktualizację stanu w React poprzez kolejkę update Hook, planer, fazę renderowania, proces porównywania i komitowanie, oraz zobacz, dlaczego stan nigdy nie zmienia się natychmiastowo.