Architecture des formulaires Vue 3 pour applications métier : modèles réutilisables
Architecture des formulaires Vue 3 pour applications métier : composants de champ réutilisables, règles de validation claires et façons pratiques d'afficher les erreurs serveur sur chaque input.

Pourquoi le code des formulaires se dégrade dans les vraies applications métier
Un formulaire dans une application métier reste rarement petit. Il commence par « juste quelques champs », puis grossit en dizaines de champs, sections conditionnelles, permissions et règles qui doivent rester synchronisées avec la logique backend. Après quelques changements produit, le formulaire fonctionne toujours, mais le code devient fragile.
L'architecture des formulaires Vue 3 compte parce que c'est là que s'accumulent les « rustines » : encore un watcher, encore un cas spécial, encore un composant copié. Ça fonctionne aujourd'hui, mais c'est de plus en plus difficile à faire confiance et à modifier.
Les signes avant-coureurs sont familiers : comportement d'entrée répété sur plusieurs pages (libellés, formatage, marqueurs requis, aides), placement des erreurs incohérent, règles de validation dispersées entre composants, et erreurs backend réduites à une notification générique qui ne dit pas à l'utilisateur quoi corriger.
Ces incohérences ne sont pas que des problèmes de style de code. Elles deviennent des problèmes d'UX : les gens renvoient des formulaires, les tickets support augmentent, et les équipes évitent de toucher aux formulaires parce qu'un cas limite caché pourrait casser quelque chose.
Une bonne organisation rend les formulaires ennuyeux — et c'est une bonne chose. Avec une structure prévisible, vous pouvez ajouter des champs, changer des règles et gérer les réponses serveur sans tout rebrancher.
Vous voulez un système de formulaire qui offre réutilisation (un champ se comporte de la même façon partout), clarté (règles et gestion des erreurs faciles à relire), comportement prévisible (touched, dirty, reset, submit) et meilleur retour (les erreurs serveur apparaissent sur les champs exacts à corriger). Les patterns ci-dessous se concentrent sur des composants de champ réutilisables, une validation lisible et le mappage des erreurs serveur vers des champs spécifiques.
Un modèle mental simple pour la structure d'un formulaire
Un formulaire qui tient dans le temps est un petit système avec des parties claires, pas un amas de champs.
Pensez en quatre couches qui se parlent dans une seule direction : l'UI collecte la saisie, l'état du formulaire la stocke, la validation explique ce qui ne va pas, et la couche API charge et sauvegarde.
Les quatre couches (et ce que chacune possède)
- Composant UI de champ : affiche l'entrée, le libellé, l'aide et le texte d'erreur. Émet les changements de valeur.
- État du formulaire : contient les valeurs et les erreurs (plus touched et dirty).
- Règles de validation : fonctions pures qui lisent les valeurs et renvoient des messages d'erreur.
- Appels API : chargent les données initiales, soumettent les changements et traduisent les réponses serveur en erreurs de champ.
Cette séparation limite l'impact des changements. Quand arrive une nouvelle exigence, vous mettez à jour une couche sans casser les autres.
Ce qui appartient à un champ vs le formulaire parent
Un composant de champ réutilisable doit être ennuyeux. Il ne doit pas connaître votre API, votre modèle de données ou vos règles de validation. Il ne doit qu'afficher une valeur et montrer une erreur.
Le formulaire parent coordonne tout le reste : quels champs existent, où vivent les valeurs, quand valider et comment soumettre.
Une règle simple aide : si une logique dépend d'autres champs (par exemple « State » est requis seulement quand « Country » est US), gardez-la dans le formulaire parent ou la couche de validation, pas dans le composant de champ.
Quand ajouter un nouveau champ est vraiment peu coûteux, vous touchez généralement seulement les valeurs par défaut ou le schéma, le markup où le champ est placé, et les règles de validation du champ. Si ajouter un champ force des changements dans des composants non liés, vos frontières sont floues.
Composants de champ réutilisables : quoi standardiser
Quand les formulaires grossissent, la victoire la plus rapide est d'arrêter de construire chaque entrée comme un cas unique. Les composants de champ doivent être prévisibles. C'est ce qui les rend rapides à utiliser et faciles à relire.
Un ensemble pratique de briques :
- BaseField : wrapper pour le libellé, l'aide, le texte d'erreur, l'espacement et les attributs d'accessibilité.
- Composants d'entrée : TextInput, SelectInput, DateInput, Checkbox, etc. Chacun se concentre sur le contrôle.
- FormSection : regroupe des champs liés avec un titre, un court texte d'aide et un espacement cohérent.
Pour les props, gardez un petit ensemble et appliquez-le partout. Changer le nom d'une prop sur 40 formulaires est pénible.
Ces props rapportent immédiatement :
modelValueetupdate:modelValuepourv-modellabelrequireddisablederror(message unique, ou un tableau si vous préférez)hint
Les slots permettent la flexibilité sans casser la cohérence. Gardez la mise en page de BaseField stable, mais autorisez de petites variations comme une action à droite ("Envoyer le code") ou une icône en tête. Si une variation apparaît deux fois, faites-en un slot plutôt que de forker le composant.
Standardisez l'ordre de rendu (libellé, contrôle, aide, erreur). Les utilisateurs scannent plus vite, les tests deviennent plus simples, et le mappage des erreurs serveur devient direct parce que chaque champ a un endroit évident pour afficher les messages.
État du formulaire : valeurs, touched, dirty et reset
La plupart des bugs de formulaire dans les applications métier ne viennent pas des entrées. Ils viennent d'un état éparpillé : valeurs à un endroit, erreurs ailleurs, et un bouton reset qui ne marche qu'à moitié. Une architecture de formulaire Vue 3 propre commence par une forme d'état cohérente.
D'abord, choisissez une convention de nommage pour les clés de champ et respectez-la. La règle la plus simple : la clé de champ égale la clé du payload API. Si votre serveur attend first_name, la clé du formulaire doit être first_name aussi. Ce petit choix rend la validation, la sauvegarde et le mappage des erreurs serveur beaucoup plus simples.
Gardez l'état du formulaire en un seul endroit (un composable, un store Pinia, ou un composant parent), et faites en sorte que chaque champ lise et écrive via cet état. Une structure plate suffit pour la plupart des écrans. N'optez pour du nested que si votre API est vraiment imbriquée.
const state = reactive({
values: { first_name: '', last_name: '', email: '' },
touched: { first_name: false, last_name: false, email: false },
dirty: { first_name: false, last_name: false, email: false },
errors: { first_name: '', last_name: '', email: '' },
defaults: { first_name: '', last_name: '', email: '' }
})
Une façon pratique de penser aux flags :
touched: l'utilisateur a-t-il interagi avec ce champ ?dirty: la valeur est-elle différente de la valeur par défaut (ou de la dernière valeur sauvegardée) ?errors: quel message l'utilisateur doit-il voir maintenant ?defaults: vers quoi réinitialisons-nous ?
Le comportement de reset doit être prévisible. Quand vous chargez un enregistrement existant, définissez à la fois values et defaults à partir de la même source. Puis reset() peut recopier defaults dans values, effacer touched, effacer dirty et effacer errors.
Exemple : un formulaire de profil client charge email depuis le serveur. Si l'utilisateur l'édite, dirty.email devient true. S'il clique sur Reset, l'email revient à la valeur chargée (pas une chaîne vide), et l'écran redevient propre.
Règles de validation lisibles
Une validation lisible dépend moins de la librairie que de la façon dont vous exprimez les règles. Si vous pouvez jeter un coup d'œil à un champ et comprendre ses règles en quelques secondes, votre code de formulaire restera maintenable.
Choisissez un style de règles auquel vous pouvez vous tenir
La plupart des équipes s'installent sur l'une de ces approches :
- Règles par champ : les règles vivent près de l'usage du champ. Simple à parcourir, idéal pour les formulaires petits à moyens.
- Règles basées sur un schéma : les règles vivent dans un objet ou fichier central. Parfait quand plusieurs écrans réutilisent le même modèle.
- Hybride : règles simples près des champs, règles partagées ou complexes dans un schéma central.
Quelle que soit l'approche, gardez les noms des règles et les messages prévisibles. Quelques règles communes (required, length, format, range) valent mieux qu'une longue liste d'helpers ad hoc.
Écrivez les règles comme de l'anglais simple
Une bonne règle se lit comme une phrase : « L'email est requis et doit ressembler à un email. » Évitez les one-liners astucieux qui cachent l'intention.
Pour la plupart des formulaires métier, renvoyer un message par champ à la fois (la première erreur) calme l'UI et aide l'utilisateur à corriger plus vite.
Règles courantes et conviviales :
- Required uniquement quand l'utilisateur doit vraiment remplir le champ.
- Length avec des nombres réels (par exemple 2 à 50 caractères).
- Format pour email, téléphone, code postal, sans regex trop strictes qui rejettent des saisies valides.
- Range comme « date pas dans le futur » ou « quantité entre 1 et 999 ».
Rendre les vérifications asynchrones visibles
La validation asynchrone (comme « nom d'utilisateur déjà pris ») devient confuse si elle se déclenche en silence.
Déclenchez ces vérifications au blur ou après une courte pause, affichez un état clair « Vérification... », et annulez ou ignorez les requêtes obsolètes quand l'utilisateur continue de taper.
Décidez quand la validation s'exécute
Le timing compte autant que les règles. Une configuration conviviale est :
- Au changement pour les champs qui bénéficient d'un feedback en direct (comme la robustesse d'un mot de passe), mais restez léger.
- Au blur pour la plupart des champs, afin que l'utilisateur puisse taper sans erreurs constantes.
- Au submit pour tout valider une dernière fois.
Mapper les erreurs serveur vers le bon input
Les vérifications côté client ne racontent qu'une partie de l'histoire. Dans les applications métier, le serveur refuse souvent les sauvegardes pour des règles que le navigateur ne peut connaître : doublons, vérifications de permission, données obsolètes, changements d'état, etc. Une bonne UX de formulaire consiste à transformer cette réponse en messages clairs placés à côté des bons champs.
Normalisez les erreurs dans une forme interne unique
Les backends n'ont pas le même format d'erreur. Certains renvoient un objet, d'autres des listes, d'autres des maps imbriquées indexées par nom de champ. Convertissez tout ce que vous recevez en une forme interne unique que votre formulaire peut rendre.
// ce que votre code de formulaire consomme
{
fieldErrors: { "email": ["Already taken"], "address.street": ["Required"] },
formErrors: ["You do not have permission to edit this customer"]
}
Gardez quelques règles cohérentes :
- Stockez les erreurs de champ sous forme de tableaux (même s'il n'y a qu'un message).
- Convertissez les styles de chemin différents en un seul style (la notation par points fonctionne bien :
address.street). - Conservez les erreurs non liées à un champ séparément sous
formErrors. - Gardez la charge utile brute pour le logging, mais ne la rendez pas.
Mapper les chemins serveur aux clés de vos champs
La partie délicate est d'aligner l'idée qu'a le serveur d'un « chemin » avec les clés de champ de votre formulaire. Décidez de la clé pour chaque champ composant (par exemple email, profile.phone, contacts.0.type) et respectez-la.
Écrivez ensuite un petit mapper qui gère les cas courants :
address.street(notation par points)address[0].street(crochets pour les tableaux)/address/street(style JSON Pointer)
Après normalisation, <Field name="address.street" /> devrait pouvoir lire fieldErrors["address.street"] sans cas particuliers.
Supportez des alias si nécessaire. Si le backend renvoie customer_email mais que votre UI utilise email, gardez un mapping comme { customer_email: "email" } pendant la normalisation.
Erreurs de champ, erreurs au niveau du formulaire et focus
Toutes les erreurs n'appartiennent pas à un seul input. Si le serveur dit « Limite du plan atteinte » ou « Paiement requis », affichez-le au-dessus du formulaire comme message de niveau formulaire.
Pour les erreurs spécifiques à un champ, affichez le message à côté de l'entrée et guidez l'utilisateur vers le premier problème :
- Après avoir défini les erreurs serveur, trouvez la première clé dans
fieldErrorsqui existe dans votre formulaire rendu. - Faites-la défiler en vue et focalisez-la (en utilisant un ref par champ et
nextTick). - Effacez les erreurs serveur d'un champ quand l'utilisateur modifie à nouveau ce champ.
Étape par étape : assembler l'architecture
Les formulaires restent calmes quand vous décidez tôt ce qui appartient à l'état, à l'UI, à la validation et à l'API, puis les connectez avec quelques petites fonctions.
Une séquence qui fonctionne pour la plupart des applications métier :
- Commencez avec un modèle de formulaire unique et des clés de champ stables. Ces clés deviennent le contrat entre composants, validateurs et erreurs serveur.
- Créez un wrapper BaseField pour le libellé, le texte d'aide, la marque « requis » et l'affichage des erreurs. Gardez les composants d'entrée petits et cohérents.
- Ajoutez une couche de validation qui peut valider par champ et valider tout au submit.
- Soumettez à l'API. Si ça échoue, traduisez les erreurs serveur en
{ [fieldKey]: message }pour que le bon champ affiche le bon message. - Séparez la gestion du succès (reset, notification, navigation) pour qu'elle ne se répande pas dans les composants et validateurs.
Un point de départ simple pour l'état :
const values = reactive({ email: '', name: '', phone: '' })
const touched = reactive({ email: false, name: false, phone: false })
const errors = reactive({}) // { email: '...', name: '...' }
Votre BaseField reçoit label, error et peut-être touched, et affiche le message au même endroit. Chaque composant d'entrée ne se préoccupe que du binding et de l'émission des mises à jour.
Pour la validation, gardez les règles proches du modèle en utilisant les mêmes clés :
const rules = {
email: v => (!v ? 'Email is required' : /@/.test(v) ? '' : 'Enter a valid email'),
name: v => (v.length < 2 ? 'Name is too short' : ''),
}
function validateAll() {
Object.keys(rules).forEach(k => {
const msg = rules[k](values[k])
if (msg) errors[k] = msg
else delete errors[k]
touched[k] = true
})
return Object.keys(errors).length === 0
}
Quand le serveur répond avec des erreurs, mappez-les en utilisant les mêmes clés. Si l'API renvoie { "field": "email", "message": "Already taken" }, faites errors.email = 'Already taken' et marquez-le touched. Si l'erreur est globale (comme "permission denied"), affichez-la au-dessus du formulaire.
Scénario d'exemple : édition d'un profil client
Imaginez un écran admin interne où un agent support édite le profil d'un client. Le formulaire contient quatre champs : name, email, phone et role (Customer, Manager, Admin). Il est petit, mais il montre les problèmes courants.
Les règles côté client doivent être claires :
- Name : requis, longueur minimale.
- Email : requis, format email valide.
- Phone : optionnel, mais si rempli il doit correspondre au format accepté.
- Role : requis, et parfois conditionnel (seuls certains utilisateurs peuvent attribuer Admin).
Un contrat de composant cohérent aide : chaque champ reçoit la valeur courante, le texte d'erreur courant (s'il y en a), et quelques booléens comme touched et disabled. Les libellés, les marques « requis », les espacements et le style d'erreur ne doivent pas être réinventés sur chaque écran.
Maintenant le flux UX. L'agent édite l'email, fait tab-out, et voit un message inline sous Email si le format est incorrect. Il le corrige, clique Sauvegarder, et le serveur répond :
- email already exists : affichez-le sous Email et focalisez ce champ.
- phone invalid : affichez-le sous Phone.
- permission denied : affichez un message global en haut du formulaire.
Si vous gardez les erreurs indexées par nom de champ (email, phone, role), le mappage est simple. Les erreurs de champ atterrissent à côté des champs, les erreurs globales vont dans une zone dédiée.
Erreurs courantes et comment les éviter
Gardez la logique au même endroit
Copier les règles de validation dans chaque écran semble rapide jusqu'à ce que les politiques changent (règles de mot de passe, identifiants fiscaux obligatoires, domaines autorisés). Centralisez les règles (schéma, fichier de règles, fonction partagée) et faites consommer les formulaires par le même jeu de règles.
Évitez aussi de laisser les composants bas niveau faire trop de choses. Si votre <TextField> sait appeler l'API, relancer en cas d'échec et parser les payloads d'erreur serveur, il cesse d'être réutilisable. Les composants de champ doivent rendre, émettre les changements de valeur et afficher les erreurs. Placez les appels API et la logique de mapping dans le conteneur de formulaire ou un composable.
Symptômes que vous mélangez les responsabilités :
- Le même message de validation est écrit à plusieurs endroits.
- Un composant de champ importe un client API.
- Changer un endpoint casse plusieurs formulaires non liés.
- Les tests requièrent de monter la moitié de l'app juste pour vérifier une entrée.
Pièges UX et accessibilité
Une seule bannière d'erreur comme « Quelque chose s'est mal passé » ne suffit pas. Les gens doivent savoir quel champ est en faute et quoi faire ensuite. Utilisez des bannières pour les échecs globaux (réseau, permission), et mappez les erreurs serveur sur des champs spécifiques pour que les utilisateurs puissent agir rapidement.
Les problèmes de chargement et de double soumission créent des états confus. Lors de l'envoi, désactivez le bouton d'envoi, désactivez les champs qui ne doivent pas changer pendant la sauvegarde, et affichez un état occupé clair. Assurez-vous que reset et annuler restaurent correctement le formulaire.
Les bases de l'accessibilité sont faciles à négliger avec des composants personnalisés. Quelques choix préviennent les vraies douleurs :
- Chaque input a un label visible (pas seulement un placeholder).
- Les erreurs sont connectées aux champs avec les attributs aria appropriés.
- Le focus se déplace vers le premier champ invalide après le submit.
- Les champs désactivés sont réellement non-interactifs et annoncés correctement.
- La navigation au clavier fonctionne du début à la fin.
Checklist rapide et prochaines étapes
Avant de livrer un nouveau formulaire, passez cette checklist. Elle attrape les petites lacunes qui deviennent des tickets support plus tard.
- Chaque champ a-t-il une clé stable qui correspond au payload et à la réponse serveur (y compris les chemins imbriqués comme
billing.address.zip) ? - Pouvez-vous rendre n'importe quel champ avec une API de composant cohérente (valeur en entrée, événements en sortie, erreur et hint en entrée) ?
- Au submit, validez-vous une fois, bloquez-vous les doubles envois, et focalisez-vous le premier champ invalide pour indiquer où commencer ?
- Pouvez-vous afficher les erreurs au bon endroit : par champ (à côté de l'entrée) et au niveau du formulaire (message général quand nécessaire) ?
- Après succès, réinitialisez-vous correctement l'état (values, touched, dirty) pour que la prochaine édition commence proprement ?
Si l'une des réponses est « non », corrigez cela en priorité. La douleur la plus fréquente avec les formulaires vient d'un décalage : les noms de champ dérivent de l'API, ou les erreurs serveur reviennent dans une forme que votre UI ne peut pas placer.
Si vous construisez des outils internes et voulez aller plus vite, AppMaster (appmaster.io) suit les mêmes fondamentaux : gardez l'UI des champs cohérente, centralisez règles et workflows, et faites apparaître les réponses serveur là où les utilisateurs peuvent agir dessus.
FAQ
Standardisez-les dès que vous retrouvez les mêmes libellés, aides, marques « obligatoire », espacements et styles d'erreur répétés sur plusieurs pages. Si une petite modification implique d'éditer de nombreux fichiers, un wrapper BaseField partagé et quelques composants d'entrée cohérents feront vite gagner du temps.
Gardez le composant de champ « idiot » : il affiche le libellé, le contrôle, l'aide et l'erreur, et émet les mises à jour de valeur. Placez la logique inter-champs, les règles conditionnelles et tout ce qui dépend d'autres valeurs dans le formulaire parent ou la couche de validation afin que le champ reste réutilisable.
Utilisez des clés stables qui correspondent par défaut au payload de votre API, comme first_name ou billing.address.zip. Cela simplifie la validation et le mappage des erreurs serveur car vous n'avez pas à traduire constamment les noms entre les couches.
Un modèle simple contient values, errors, touched, dirty et defaults. Quand tout lit et écrit via la même forme, le comportement de reset et d'envoi devient prévisible et vous évitez les bugs de « demi-reset ».
Initialisez à la fois values et defaults à partir des mêmes données chargées. Ensuite reset() doit recopier defaults dans values et effacer touched, dirty et errors pour que l'interface redevienne propre et corresponde à ce que le serveur a renvoyé.
Commencez avec des règles simples sous forme de fonctions indexées par les mêmes clés que votre état de formulaire. Retournez un seul message clair par champ (la première erreur) pour que l'interface reste calme et que l'utilisateur sache quoi corriger en priorité.
Validez la plupart des champs au blur, puis validez tout au submit comme filet de sécurité final. N'utilisez la validation « on change » que là où elle apporte un vrai bénéfice (par exemple la robustesse d'un mot de passe) pour ne pas harceler l'utilisateur pendant la saisie.
Lancez les vérifications asynchrones au blur ou après un court debounce, et affichez un état explicite « vérification en cours ». Annulez ou ignorez les requêtes obsolètes pour que des réponses lentes ne remplacent pas des saisies plus récentes et n'introduisent pas d'erreurs déroutantes.
Normalisez tous les formats backend en une seule forme interne comme { fieldErrors: { key: [messages] }, formErrors: [messages] }. Utilisez un style de chemin unique (la notation par points fonctionne bien) pour qu'un champ nommé address.street puisse toujours lire fieldErrors['address.street'] sans cas spéciaux.
Affichez les erreurs globales au-dessus du formulaire, mais placez les erreurs de champ à côté de l'entrée concernée. Après un échec d'envoi, mettez le focus sur le premier champ en erreur et supprimez l'erreur serveur de ce champ dès que l'utilisateur le modifie à nouveau.


