Skip to content

Le contrat de formulaire ​

Le problème ​

Un composant de saisie doit savoir quatre choses : son nom, sa valeur, comment signaler un changement, et quel message d'erreur afficher. Il n'a aucune raison de savoir d'où cela vient.

Or c'est exactement ce que la version précédente de Form faisait :

tsx
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

Deux imports, et tout le paquet devient inutilisable pour qui a choisi Formik, TanStack Form, ou trois useState. C'est la raison pour laquelle Form est resté hors du barrel et hors du typecheck depuis le début : il ne pouvait pas y entrer.

Le contrat ​

@sia-ui/headless/form.ts ne contient aucun import. Il décrit une forme.

ts
export interface FieldBinding<TValue = unknown> {
  name: string;
  value: TValue;
  onChange: (value: TValue) => void;
  onBlur: () => void;
  error?: string | undefined;
  disabled?: boolean | undefined;
  required?: boolean | undefined;
}

onChange reçoit la valeur, pas l'événement. C'est le point qui rend le contrat portable : un <input> du DOM et un TextInput natif produisent des événements différents, mais la même valeur. L'adaptation de l'événement appartient au composant, qui seul connaît sa plateforme.

Au-dessus, FormAdapter décrit le formulaire entier — état, validation, envoi, erreurs serveur :

ts
export interface FormAdapter<TValues> {
  state: FormState<TValues>;
  bind: (name, options?) => FieldBinding;
  setValue / setValues / setError / setErrors / reset / submit / validate;
}

Les trois couches ​

Le composant ne dépend que du contrat. Changer de bibliothèque de formulaires, c'est changer d'adaptateur — pas un composant n'est touché.

Le contrat vit dans @sia-ui/headless, auprès de useLocalForm, son implémentation de référence — voir 39 — Frontières entre paquets. Les deux vont ensemble, et ni l'un ni l'autre ne touche le DOM.

useLocalForm, l'implémentation de référence ​

Elle vit dans @sia-ui/headless et n'a aucune dépendance. Elle sert à la fois de repli pour les projets qui n'ont pas de bibliothèque, et de preuve que le contrat suffit.

tsx
const form = useLocalForm({
  defaultValues: { nom: "", email: "" },
  validate: (values) => {
    const errors: Record<string, string> = {};
    if (!values.nom.trim()) errors.nom = "Le nom est obligatoire";
    if (!values.email.includes("@")) errors.email = "Adresse invalide";
    return errors;
  },
  onSubmit: (values) => api.post("/clients", { body: values }),
});

<Field label="Nom du client" required field={form.bind("nom")} />
<Field label="Adresse e-mail" type="email" field={form.bind("email")} />

Zod, sans dépendance de la bibliothèque ​

validate est une fonction. Un schéma s'y branche en cinq lignes, dans le projet, jamais dans le paquet :

ts
const validate = (values: Values) => {
  const parsed = schema.safeParse(values);
  if (parsed.success) return {};
  return Object.fromEntries(
    parsed.error.issues.map((issue) => [issue.path[0], issue.message]),
  );
};

Deux décisions qui se voient à l'usage ​

L'erreur n'apparaît qu'après coup. bind ne rend error que si le champ a été quitté au moins une fois, ou si un envoi a déjà été tenté. Reprocher « champ obligatoire » sur un formulaire encore vierge, c'est reprocher à quelqu'un de ne pas avoir fini de le remplir.

isDirty compare les tableaux par contenu. Sans cela, un MultiSelect qui reconstruit son tableau à chaque rendu active le bouton d'envoi tout seul.

L'adaptateur react-hook-form ​

Il est servi par le registre, pas par le paquet :

bash
pnpm dlx @sia-ui/cli add form-adapter-rhf
pnpm add react-hook-form

Le fichier arrive dans src/lib/form-adapter-rhf.tsx. C'est du code du projet à partir de là — modifiable, remplaçable, supprimable.

tsx
const form = useRhfFormAdapter<Values>({
  resolver: zodResolver(schema),
  defaultValues,
  onSubmit: (values) => api.post("/clients", { body: values }),
});

<Field label="Nom" field={form.bind("nom")} />

Le travail de l'adaptateur tient à une traduction : register de react-hook-form rend des gestionnaires qui reçoivent un événement DOM, le contrat transporte la valeur. form reste accessible pour tout ce que le contrat ne couvre pas — tableaux de champs, watch ciblé, contexte partagé.

Ce fichier n'est pas compilé avec le paquet. Il est le seul dans ce cas : src/adapters/ est exclu du tsconfig de @sia-ui/react-web, puisqu'il importe une dépendance que le dépôt n'installe pas. C'est le prix de l'absence de dépendance — ce fichier est validé par les projets qui l'utilisent, pas par la CI du design system.

Les erreurs du serveur ​

Un serveur qui refuse une saisie le dit de deux façons, et le formulaire comprend les deux.

En rendant les erreurs. Le gestionnaire d'envoi peut rendre { champ: message } — le type FormSubmitResult. useLocalForm, l'adaptateur react-hook-form et Form les posent sous les champs ; l'envoi est tenu pour non abouti, et CrudPage garde sa boîte ouverte.

ts
onSubmit={async (values) => {
  const reponse = await api.post("/clients", { body: values }).catch((e) => e);
  if (reponse instanceof HttpError) return reponse.toFormErrors();
}}

En levant. Form intercepte le rejet — il ne devient plus une promesse non gérée — et le lit avec readSubmitError de @sia-ui/headless :

readSubmitError ne dépend pas de @sia-ui/api : il lit toFormErrors() ou, à défaut, fields: [{ field, message }]. Une HttpError en expose un ; une erreur tRPC ou axios le peut aussi. Un 409 sans champ s'affiche dans l'alerte avec son message ; une erreur sans message, avec le libellé submitError (« L'enregistrement a échoué. »), réglable par SiaProvider.

Pour un serveur NestJS, le client se règle une fois — voir Module API :

ts
const api = createApiClient({ baseURL: "/api", fieldErrors: nestFieldErrors });

Dans useLocalForm, les erreurs posées par setErrors vivent à part de la validation locale. Relancée à chaque sortie de champ, celle-ci les effaçait avant qu'on ait pu les lire ; désormais une erreur du serveur reste affichée jusqu'à ce que son champ soit modifié, ou jusqu'au prochain envoi. Elle ne compte pas dans isValid — comme dans react-hook-form : seul un nouvel envoi dira si elle tient encore.

Ce que Field accepte ​

La prop field est typée FieldBindingLike, pas FieldBinding<FieldValue> :

ts
export interface FieldBindingLike {
  name: string;
  value: FieldValue;
  onChange(value: unknown): void; // syntaxe de méthode, volontairement
  onBlur(): void;
  error?: string | undefined;
  disabled?: boolean | undefined;
  required?: boolean | undefined;
}

onChange est écrit en syntaxe de méthode. TypeScript compare alors ses paramètres de façon bivariante, ce qui laisse passer un FieldBinding<string> — ce que rend form.bind("email") — là où le champ manipule une valeur plus large. Écrit en syntaxe de propriété, chaque branchement demanderait un as.

Les props écrites explicitement l'emportent sur le branchement, pour corriger un cas isolé sans le démonter :

tsx
<Field field={form.bind("montant")} disabled={!peutModifier} />

Form, le formulaire déclaratif ​

Il est resté hors du barrel et hors du typecheck depuis le début du dépôt, parce qu'il importait react-hook-form et zodResolver. Il est réécrit sur le contrat et n'importe plus rien.

tsx
<Form
  columns={2}
  defaultValues={{ nom: "", email: "" }}
  validate={createValidator({ nom: [required()], email: [required(), email()] })}
  onSubmit={(values) => api.post("/clients", { body: values })}
  fields={[
    { group: "Identité", fields: [
      { name: "nom", label: "Raison sociale", required: true, colSpan: 2 },
      { name: "email", label: "Adresse e-mail", type: "email", required: true },
    ] },
  ]}
/>

Sans form, il monte un useLocalForm en interne. Avec — n'importe quel adaptateur — il ne fait plus que rendre, et l'état appartient au dehors :

tsx
const form = useRhfFormAdapter<Valeurs>({ resolver: zodResolver(schema) });
<Form fields={champs} form={form} />

Deux détails qui ne se devinent pas.

Ses valeurs sont typées FormShape — Record<string, FieldValue> — et non FormValues. C'est plus étroit volontairement : ce formulaire rend ses champs avec Field, qui ne sait afficher que ces types-là. Un objet imbriqué n'aurait aucun contrôle pour le montrer.

Un groupe est un fieldset avec sa legend, pas un div avec un titre. Le lecteur d'écran annonce alors le nom du groupe avant chaque champ qu'il contient — ce qui est exactement ce qu'un groupe veut dire.

Les secrets, en écriture seule ​

Des variables d'environnement, des clés d'API : SecretFields les saisit par paires nom / valeur. Les noms se lisent ; les valeurs ne reviennent jamais du serveur, et ne doivent pas y revenir.

tsx
const [secrets, setSecrets] = useState<SecretEntry[]>(
  enregistres.map((key) => ({ key, value: "" })),
);

<SecretFields
  value={secrets}
  onValueChange={setSecrets}
  storedKeys={enregistres}                 // noms déjà enregistrés
  templates={["DATABASE_URL", "API_KEY"]}  // proposés d'un clic
  keyPattern={/^[A-Z_][A-Z0-9_]*$/}
/>

// à l'envoi : ce qui change, calculé une fois pour tous les projets
const { set, remove, missing } = secretChanges(secrets, enregistres);
if (missing.length > 0) return; // noms nouveaux sans valeur : à compléter
await api.patch(`/projets/${id}/secrets`, { body: { set, remove } });

// après l'envoi : les valeurs s'effacent, les noms restent
setSecrets(clearSecretValues(secrets));
SituationSens
nom enregistré, valeur videinchangé
nom enregistré, valeur saisieremplacé — une rotation
nom retiré de la listeà supprimer côté serveur
nouveau nom, avec une valeurà créer
nouveau nom, sans valeurmissing — à signaler, rien n'est envoyé
nom enregistré renommél'ancien dans remove, le nouveau dans set

Les valeurs sont des PasswordInput en autocomplete="new-password", le seul réglage que les navigateurs respectent pour ne pas proposer le mot de passe du compte à la place d'une clé. Les noms en double et ceux qui ne suivent pas keyPattern sont signalés sous la ligne fautive.

Les vérifications auprès du serveur ​

Un identifiant libre, un nom de domaine disponible : certaines règles ne se vérifient qu'en demandant au serveur. validateAsync se déclare sur le champ, comme le reste :

tsx
<Form
  defaultValues={{ slug: "" }}
  onSubmit={creerProjet}
  fields={[
    {
      name: "slug",
      label: "Identifiant",
      validText: "Disponible",
      validateAsync: async (valeur, { signal }) => {
        const libre = await api.get<boolean>(`/projets/disponible/${valeur}`, { signal });
        return libre ? undefined : "Cet identifiant est déjà utilisé.";
      },
    },
  ]}
/>

La frappe suivante annule la vérification en cours, requête comprise, par son signal. Une valeur vide n'est pas vérifiée — l'obligation reste à la validation locale, qui passe aussi devant : une valeur mal formée n'a pas à être envoyée au serveur pour être refusée. Le mécanisme est useAsyncValidation de @sia-ui/headless, utilisable hors de Form avec n'importe quel adaptateur.

submitProps.disabled s'ajoute désormais aux conditions du formulaire au lieu d'être remplacé par elles — et Entrée dans un champ respecte la même garde que le bouton.

Publié sous licence MIT.