Accueil / Articles / Formulaires React pilotés par des schémas : affichage et validation à partir de JSON Schema

Formulaires React pilotés par des schémas : affichage et validation à partir de JSON Schema

Comment rendre des formulaires React validés directement à partir d’un JSON Schema, gérer les éléments $ref, oneOf ainsi que les branches if/then, intégrer des widgets personnalisés et éviter les pièges courants de validation.

2345 mots

Un formulaire React manuscrit duplique généralement un contrat déjà existant. Le schéma de demande de l’API indique que email est obligatoire et doit ressembler à une adresse e-mail, que age est un entier non négatif, et que role doit être l’un de trois valeurs. Réécrire ces règles dans JSX, puis dans une bibliothèque de validation, et enfin dans les messages d’erreur crée trois sources de vérité pour une même structure de données, qui finissent par diverger dès que le backend change.

Ce guide considère plutôt que le formulaire est dérivé d’un JSON Schema, en utilisant le paquet open source react-simple-schema-form comme implémentation concrète. Vous verrez comment $ref, allOf, oneOf ainsi que if/then permettent de créer des champs dynamiques, comment intégrer des widgets personnalisés, et quels comportements de validation donnent l’impression à un formulaire généré d’avoir été conçu manuellement.

Pourquoi le schéma doit gérer le formulaire

Les dérives sont prévisibles : un nouveau champ côté backend n’atteint jamais le formulaire, et une demande telle que « afficher uniquement l’adresse de facturation pour les paiements par facture » se transforme en un drapeau useState, en un affichage conditionnel et en une branche de validation qui finissent par être désynchronisés des mois plus tard.

JSON Schema peut déjà exprimer chacune de ces règles : types, contraintes, champs obligatoires et logique conditionnelle. Il s’agit souvent du même document que votre backend utilise pour valider les demandes et que votre spécification OpenAPI intègre. Si le formulaire est généré à partir de ce schéma, toute modification du schéma met à jour l’interface utilisateur ainsi que sa validation en une seule opération.

Un formulaire généré minimal

react-simple-schema-form accepte un schéma JSON conforme à draft-07 et affiche un formulaire validé. Selon sa documentation, il ne comporte aucune dépendance en temps de exécution à part React 18, il inclut ses propres types TypeScript et propose un fichier de style optionnel. Son installation se fait via un seul paquet :

npm install react-simple-schema-form

L’exemple ci-dessous décrit un petit objet utilisateur : un nom, une adresse e-mail avec format: 'email', un âge entier dont la valeur minimale est zéro, ainsi qu’un rôle défini par enum. name et email sont indiqués comme obligatoires. Le composant reçoit le schéma ainsi qu’un callback onSubmit, et rien d’autre.

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

D’après ce schéma, la bibliothèque affiche un champ de texte, un champ d’email, un champ numérique et un sélecteur pour l’énumération, met en évidence les champs obligatoires, affiche des erreurs en ligne et appelle onSubmit uniquement lorsque les données sont valides. Le composant fonctionne dans les deux modes React : on peut lui transmettre value et onChange pour le contrôler, ou defaultValue pour lui permettre de gérer son propre état.

Tout générateur gère un objet plat de cette manière ; la véritable épreuve réside dans les schémas imbriqués et ramifiés.

Gestion des schémas qui ne sont pas plats

Les schémas de production réutilisent des définitions, composent des fragments et prennent en compte les branches des données. La bibliothèque résout tout cela en fonction des données du formulaire actuel avant chaque rendu, de sorte que chaque champ ne voit qu’un schéma aplati.

Réutilisation des définitions avec $ref et allOf

address peut être référencée en deux endroits et s’affichera comme deux sections indépendantes. Les mots-clés placés à côté d’un $ref remplacent la définition référencée, de sorte que { "$ref": "#/definitions/address", "title": "Shipping address" } génère un bloc d’adresse intitulé « Adresse de livraison ». Avec allOf, les parties sont fusionnées en profondeur : les propriétés imbriquées sont fusionnées de manière récursive et les tableaux required sont combinés en leur union.

Traiter oneOf comme une union discriminée

De nombreux générateurs ont du mal avec oneOf. Le modèle qui fonctionne bien est une union discriminée : chaque branche assigne un champ partagé à une valeur fixe à l’aide de const, et le formulaire utilise ce champ pour choisir la branche active.

Dans le schéma de paiement ci-dessous, method est une énumération pouvant prendre les valeurs card ou bank. La première branche définit method sur card et exige une valeur pour number ; la seconde le définit sur bank et exige une valeur pour 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"] }
  ]
}

Lorsque method est changé de card à bank, le champ du numéro de carte est remplacé par le champ IBAN. Il n’y a ni état de composant ni JSX conditionnel dans votre code ; c’est uniquement le schéma qui provoque ce changement. Un oneOf dont les branches ne contiennent que des const est affiché sous forme d’un select étiqueté.

Sections conditionnelles avec if/then/else et dependencies

Les mots-clés conditionnels sont réévalués chaque fois que les données changent, y compris à chaque frappe. Une solution utile consiste à utiliser une section optionnelle qui n’est validée qu’une fois que l’utilisateur l’active. Le fragment ci-dessous définit un objet schedule doté d’un indicateur booléen enabled (par défaut false) ainsi que de deux champs représentant les jours. La clause if s’active lorsque enabled vaut true, et la clause then rend alors monday et tuesday obligatoires.

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

Lorsque l’option est désactivée, rien à l’intérieur de la section n’est obligatoire et la soumission n’est pas bloquée. Lorsqu’elle est activée, les deux champs de date reçoivent des indicateurs d’obligation et le formulaire ne peut pas être soumis tant qu’ils ne sont pas remplis. Comme cette option fait partie des données plutôt que de l’état local de l’interface, le serveur peut valider le même envoi de données selon le même schéma et parvenir à la même conclusion.

La ligne "required": ["enabled"] à l’intérieur de if est facile à supprimer mais essentielle à conserver. Dans JSON Schema, properties ne restreint que les clés présentes. Un objet ne contenant pas de clé enabled satisfait donc à { "properties": { "enabled": { "const": true } } } ; le branchement then s’active, et les champs deviennent obligatoires même si la section n’a jamais été activée. Exiger cette clé à l’intérieur de la condition comble cette faille.

Sélectionner et personnaliser les widgets

Un formulaire généré n’est pratique que si vous pouvez contrôler quel champ d’entrée chaque champ utilise. La bibliothèque conserve un registre des widgets intégrés, dont text, email, number, select, radio, checkboxes, textarea et date, et propose trois façons de les assigner :

  1. Une propriété uiSchema identifiée par chemin, avec prise en charge de glob. tags.* cible chaque élément d’un tableau, tandis que **.postalCode cible chaque code postal à n’importe quelle profondeur, même à l’intérieur d’un $ref utilisé en deux endroits. Lorsque plusieurs clés correspondent, la plus spécifique l’emporte.
  • Suggestions intégrées au schéma. Un nœud peut contenir ses propres mots-clés ui:*, et un parent peut héberger un uiSchema imbriqué référencé par des noms d’enfants, de sorte que quiconque fait référence à une définition partagée peut réajuster le style de ses enfants.
  • Une fonction resolveWidget pour des choix basés sur des règles, comme « tout entier avec format: epoch utilise le widget epoch ». Elle reçoit le schéma entièrement résolu et peut retourner soit un nom de widget, soit un composant.
  • L’ordre de priorité est fixé : le uiSchema de l’application prend le pas sur les suggestions intégrées au schéma, celles-ci prennent le pas sur les règles resolveWidget, et celles-ci prennent le pas sur les valeurs par défaut. Cette prévisibilité est importante lorsque d’une autre équipe fournit le schéma : le client peut toujours surcharger ses suggestions.

    Rédaction d’un widget personnalisé

    onChange, en plus de propriétés telles que id, required, disabled et onBlur. L’exemple ci-dessous stocke une date à l’aide de secondes Unix, mais affiche à l’utilisateur un sélecteur de date datetime-local natif. Il convertit les secondes en chaîne de date à afficher, et lors d’un changement, il reconvertit la valeur saisie en divisant les millisecondes par 1000, en passant undefined lorsque la saisie est vide ou invalide. Le widget est enregistré sous le nom epoch et attribué au champ startsAt via 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' } }} />
    

    Le schéma indique integer, l’utilisateur voit un sélecteur, et les données contiennent des secondes Unix. Une précaution : toISOString() génère du temps UTC, tandis qu’un champ de type datetime-local ainsi que l’expression new Date(e.target.value) fonctionnent tous deux dans la zone horaire locale de l’utilisateur. En dehors de UTC, l’heure affichée est décalée en fonction du décalage horaire de la zone, et chaque modification modifie la valeur stockée. Il convient donc de formater la valeur affichée à partir des parties locales de la date afin que les deux approches soient cohérentes.

    Un widget peut également gérer un objet ou un tableau entier, en recevant la valeur complète ainsi que tous les erreurs imbriquées, et en affichant ses éléments enfants à l’aide du composant <Field> exporté. C’est ainsi que la section des plannings obtient son comportement de mise en surbrillance et de masquage, sans que la bibliothèque ait connaissance des plannings eux-mêmes.

    Si un schéma fait référence à un widget qui n’a jamais été enregistré, la bibliothèque enregistre un seul avertissement et passe au champ par défaut. Une faute d’orthographe dans un schéma fourni par une autre équipe doit entraîner une dégradation progressive plutôt que de faire planter la page.

    Validation conforme aux attentes des utilisateurs

    Le validateur intégré est de petite taille et ne dépend d’aucune bibliothèque externe ; la majeure partie de sa conception concerne le moment où signaler les erreurs plutôt que leur simple existence.

    Afficher les erreurs au bon moment

    Les erreurs apparaissent après que l’utilisateur a quitté un champ, ou toutes en même temps suite à une tentative de soumission, et jamais lors du premier affichage. Lorsqu’une soumission échoue, le focus se déplace sur le premier champ invalide.

    Considérer les objets optionnels non modifiés comme absents

    Pour afficher les champs d’entrée relatifs à des objets imbriqués, le formulaire les initialise avec {}. Un validateur naïf exigerait alors les champs street et city pour une adresse optionnelle que l’utilisateur n’a jamais modifiée. La solution consiste à considérer un objet optionnel dont toutes les valeurs sont vides comme inexistant, ce qui évite toute erreur. Un objet obligatoire est toujours validé, et il indique quels enfants manquent plutôt que de se contenter d’un message vague comme « Adresse requise ».

    Erreurs de l’attribut oneOf pour la branche active

    Lorsqu’aucune branche oneOf n’est valide, un message générique du type « les données doivent correspondre à exactement un schéma » est inutile pour l’utilisateur. Au lieu de cela, le validateur détermine à quelle branche appartiennent les données en se basant sur des critères de distinction et des types, tout en ignorant l’attribut required, puis indique les erreurs au niveau des champs de cette branche. Dans le cas d’un paiement avec method: card mais sans numéro de carte, l’erreur apparaît dans le champ du numéro de carte, là où l’utilisateur regardera.

    Ne laissez jamais des champs cachés empêcher la soumission

    Les valeurs partiellement saisies restantes dans une section désactivée ne doivent pas entraîner un échec du contrôle pattern que l’utilisateur ne peut pas voir. La règle se trouve dans le schéma, mais la solution réside dans le widget : il vide la section lorsqu’elle est désactivée, et l’attribut errors indique au widget quels erreurs existent dans la partie qu’il cache.

    Réutilisez les règles en dehors de React

    Le validateur est également exporté séparément. validate(schema, data) renvoie une liste d’entrées de type { path, keyword, message }, ce qui permet d’appliquer les mêmes règles dans un service Node.js, dans un test unitaire, ou avant toute affichage. Pour une alternative basée sur TypeScript, consultez partager un schéma Zod entre React et Node.

    Documentation destinée aux assistants de codage

    Les formulaires sont souvent créés à l’aide d’un assistant de codage basé sur l’IA, c’est pourquoi le package inclut une documentation destinée aussi bien aux machines qu’aux humains :

    • Un fichier d’Agent Skill à l’adresse skills/react-simple-schema-form/SKILL.md au sein du package npm, que les outils prenant en charge le format Agent Skills peuvent charger depuis node_modules. Il couvre l’API, la priorité des widgets, les méthodes décrites ci-dessus ainsi que les pièges connus, et pèse environ 7 kB au moment de la rédaction.
    • llms.txt et llms-full.txt sur le site de démonstration, regroupant le README, les compétences et chaque schéma d’exemple dans un seul fichier qui peut être collé dans une conversation ou indexé par un serveur MCP de documentation.
    • JSDoc accompagné d’exemples pour chaque export, de sorte que le survol du curseur sur les déclarations de type explique leur utilisation.
    • Un fichier context7.json permettant à le répertoire d’être indexé correctement dans Context7.

    Cela ne fera pas choisir une bibliothèque au modèle, mais cela augmente les chances que la première tentative de l’assistant fonctionne, une pratique qui vaut également d’être adoptée pour les bibliothèques internes.

    Essayer

    La démonstration en direct place un éditeur de schéma à côté du formulaire généré, avec des données en temps réel et des erreurs en dessous. Elle inclut des exemples pour $ref, allOf, oneOf, if/then/else, dependencies ainsi que pour la sélection de widgets. Le paquet est publié sur npm, et les sources ainsi que le suivi des problèmes se trouvent sur GitHub. Il s’agit d’un projet récent, il convient donc de le tester avec vos propres schémas avant de compter sur lui.

    Points clés

    • Si une API publie déjà un JSON Schema, la génération du formulaire à partir de celui-ci élimine les règles redondantes et assure que la validation côté interface utilisateur et côté serveur soient synchronisées.
    • Résolvez les éléments $ref, allOf, oneOf ainsi que les conditions en fonction de données réelles afin que chaque champ dispose d’un schéma simplifié.
    • Modélisez les formulaires à variantes sous forme d’ unions discriminées avec const, et ajoutez toujours l’attribut required à l’intérieur des clauses if.
    • Permettez une modification de la sélection des widgets grâce à un ordre de priorité clair, en particulier pour les schémas gérés par une autre équipe.
    • De bons formulaires générés dépendent du moment de la validation : signalez les erreurs lors du retrait du focus ou de la soumission, ignorez les objets optionnels non modifiés, et indiquez que les erreurs oneOf concernent la branche active.

    Lectures complémentaires

  • Une base React prête pour la production : ce que fait réellement chaque package. — Installer Vite, Tailwind v4, Redux Toolkit, React Router, Jest et Prettier pour une application React, ainsi que comprendre pourquoi chaque package et ligne de configuration est présent.