Votre lead dev vous demande d'améliorer l'UX du formulaire de contact pour qu'il soit plus explicite dans l'affichage de ses erreurs. Il est vrai que l'UX est importante, même en tant que développeur. J'aime personnellement savoir quel champ a posé problème lorsque j'envoie un formulaire. Et les utilisateurs aussi : ils peuvent gagner en autonomie et nous expliquer l'erreur qu'ils ont rencontré.
Dans cet article, nous allons implémenter étape par étape un formulaire avec le framework Remix. D'abord sans librairie externe. Puis nous allons l'améliorer peu à peu en ajoutant Zod, puis Conform.
Cet article existe également au format vidéo sur YouTube.
Commençons par faire simple.
Implémentation d'un formulaire basique avec Remix
Avant d'installer quoi que ce soit, nous allons implémenter un formulaire HTML basique.
Nous allons d'abord :
- instantier un nouveau projet Remix avec la commande
npx create-remix@latest
-
npm install
pour installer les dépendances -
npm run dev
pour lancer le serveur de développement
Ensuite, nous allons supprimer le contenu du fichier app/routes/_index.tsx
et le remplacer par un formulaire simple.
export default function Index() {
return (
<form>
<input type='text' />
<button>S'inscrire</button>
</form>
);
}
Actuellement, nous avons implémenté un formulaire HTML qui contient un input
et un button
. Il ne nous sera pas très utile dans cet état.
Ce formulaire utilise la méthode GET par défaut. Cela signifie que chacune des données présente dans l'input seront visibles dans l'URL lors de la soumission (sous forme de paramètres d'URL). De plus, l'input
ne possède pas de propriété name
. Sa valeur ne sera pas récupérée. Elle a besoin d'avoir un nom pour être identifiée. (comme la paire clé-valeur dans un object)
Cet article va uniquement utiliser un formulaire POST, qui est idéal pour envoyer des données sensibles comme des adresses email.
Rajoutons des attributs 'form.POST' et 'input.name'
Il manque des attributs à notre formulaire:
- la méthode
POST
de notre formulaire JSX - la propriété
name
à notreinput
(qui prend également la valeur name
export default function Index() {
return (
<form method='POST'>
<input type='text' name='name' />
<button type='submit'>S'inscrire</button>
</form>
);
}
Essayons de soumettre notre formulaire !
405 Method Not Allowed
L'erreur 405 Method Not Allowed
apparaît. Mais nous n'avons pas fait d'erreur au niveau de l'implémentation du formulaire. Cette erreur nous explique qu'il faut maintenant ouvrir un API endpoint à cette URL. Ce que j'appelle API endpoint est en réalité une déclaration de route API, comme avec Express.
La page API Routes (documentation de Remix) nous explique que les fichiers de type route (dans le dossier routes
) sont leur propre API.
Pour mieux comprendre, regardons la page Fullstack Data Flow de la doc.
Le schéma ci-dessus représente l'ordre d'exécution des méthodes de Remix :
- La méthode
loader
(facultative) s'éxecute en premier lors d'un chargement de page. Exécuté côté serveur, on peut charger les données nécessaires avant d'afficher la vue. Pratique pour implémenter le SSR et très pratique pour le SEO. - Le composant React exporté par défaut représente la vue. Il s'éxecute à la fin de la méthode
loader
. Ce composant est exécuté côté serveur puis côté client. C'est dedans qu'on va ajouter nos formulaires, nos states (useState, useEffect, etc ...) - La méthode
action
(facultative) s'éxecute seulement si une soumission de formulaire POST est effectuée dans la route. Cette méthode contient toute la logique liée à notre mutation (se connecter, s'inscrire, modifier une donnée en DB ...) et va s'exécuter côté serveur.
405 Method Not Allowed signifie qu'on effectue un POST sur notre route, mais qu'on a oublié d'implémenter la logique serveur (on doit déclarer une méthode
action
)
Ajout de la méthode 'action'
La fonction action est une convention de Remix. Ajoutons cette méthode pour autoriser les requêtes POST
dans notre route.
import type { ActionFunctionArgs } from '@remix-run/node';
export const action = async ({ request }: ActionFunctionArgs) => {
return null;
};
export default function Index() {
return (
<form method='POST'>
<input type='text' name='name' />
<button type='submit'>S'inscrire</button>
</form>
);
}
L'erreur est réparée. On peut passer à la suite.
Validation d'un formulaire côté serveur
Soumettre notre formulaire (en cliquant sur le button
) effectue un POST
sur notre route, et va déclencher la méthode action
. Cette méthode ne fait rien à part renvoyer null
.
Nous allons ajouter un peu de logique pour :
- extraire les données du formulaire
- les valider manuellement
- envoyer un retour à l'utilisateur en cas d'erreur
Récupération des données de formulaire côté serveur
Avant de valider les données côté serveur, nous devons les récupérer.
Voici la solution.
import type { ActionFunctionArgs } from '@remix-run/node';
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const name = formData.get('name');
return null;
};
export default function Index() {
return (
<form method='POST'>
<input type='text' name='name' />
<button>S'inscrire</button>
</form>
);
}
Explications :
Au moment du POST
, notre formulaire va récupérer chaque valeur de ses input
possédant la propriété name
. Je reformule. Si votre input
n'a pas de propriété name
, sa valeur ne pourra être récupérée côté serveur. C'est un pré-requis. Cette propriété permet d'identifier l'input
côté serveur.
Ensuite, une requête est effectuée à notre action avec les données de notre formulaire. C'est cette requête que nous récupérons comme argument dans notre action. La request
possède plusieurs informations, notamment la méthode formData
qui va nous permettre de récupérer les données du formulaire. C'est une promesse, nous devons utiliser await
.
Cette méthode nous renvoie un objet FormData possédant plusieurs méthodes.
Le
FormData
n'est pas exclusif à l'environnement serveur. Nous pouvons utiliser cette interface côté client, par exemple en utilisant le propsonSubmit
dans notreform
.
Chaque valeur peut être récupérée en utilisant la méthode .get($key)
de notre FormData
. $key représente la valeur de la propriété name
de l'input. Dans notre cas, nous l'avons appelé name
. Je vous présenterai également un moyen de transformer le formData
en objet clé-valeur (pour ne plus avoir à utiliser formData.get()
.
Valider les données de formulaire côté serveur
Pourquoi valider nos données de formulaire ?
Pourquoi valider nos données ? Pour nous assurer que les données reçues sont conformes à ce que nous attendons. Nous allons par exemple valider qu'un numéro de téléphone contient 10 caractères. Sinon, pas la peine de sauvegarder la donnée : On imagine que le numéro est sûrement erroné.
J'ai déjà eu à valider des adresses emails, des numéro SIRET et des numéros de TVA. Ajouter une étape de validation empêche les farceurs de spam notre base de données avec des valeurs inutilisables.
Nous pouvons valider notre donnée de plusieurs manières.
- En validant "manuellement" (avec des
if
) - En utilisant la librairie tiny-invariant
- En utilisant Zod
Je n'utilise personnellement jamais la validation manuelle
Validation manuelle de nos données de formulaire
À titre d'exemple, nous allons interdire l'utilisation des prénoms à moins de 3 caractères.
Côté serveur, cela implique deux vérifications :
- Vérifier que la valeur est bien un
string
(et pas un fichier) - Vérifier que le nom fait moins de 3 caractères
Nous devons aussi renvoyer au client un message d'erreur ou de succès.
Côté client, nous faisons quelques modifications :
- Nous récupérons le message d'erreur ou de succès avec le hook useActionData
- Nous l'affichons conditionnellement à l'utilisateur.
import type { ActionFunctionArgs } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const name = formData.get('name');
if (typeof name !== 'string') {
return {
message: 'Le nom est requis',
};
}
if (name.length < 3) {
return {
message: 'Le nom doit contenir au moins 3 caractères',
};
}
return { message: 'Inscription réussie' };
};
export default function Index() {
const actionData = useActionData<typeof action>();
return (
<form method='POST'>
<input type='text' name='name' />
{actionData?.message ? <p>{actionData.message}</p> : null}
<button>S'inscrire</button>
</form>
);
}
Pour un cas d'utilisation très simple comme celui-ci, on peut s'arrêter là. Pas besoin d'installer deux librairies supplémentaires. Cependant, cet exemple ne reflète pas les applications que je pousse en production. Il y a souvent plus de trois champs aux formulaires que je développe. De plus, j'utilise déjà la librairie zod pour valider les données côté serveur (récupérées depuis une API NestJS)
Validation de nos données de formulaire avec Zod
Pour pouvoir utiliser Zod, (c'est une librairie Javascript de validation de données), nous avons besoin de l'installer dans notre dossier projet.
npm install zod
Ensuite, nous définissons un schéma représentant notre modèle de données. Reprenons l'exemple de notre nom à 3 caractères qui est de type string
. Cela implique une refactorisation du code.
Nous devons définir le schéma zod en ajoutant les instructions .string()
et .min(3)
(les messages d'erreurs sont facultatifs).
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
message: 'Le nom doit contenir au moins 3 caractères',
});
Ensuite, nous allons utiliser la méthode .parse($data)
pour déclencher la validation de notre donnée $data
(ici, ce sera notre name
).
const parsedName = nameSchema.parse(name);
Voici l'implémentation complète.
import type { ActionFunctionArgs } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
import { z } from 'zod';
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
message: 'Le nom doit contenir au moins 3 caractères',
});
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const name = formData.get('name');
const parsedName = nameSchema.parse(name);
return { message: 'Inscription réussie' };
};
export default function Index() {
const actionData = useActionData<typeof action>();
return (
<form method='POST'>
<input type='text' name='name' />
{actionData?.message ? <p>{actionData.message}</p> : null}
<button>S'inscrire</button>
</form>
);
}
Cette implémentation possède un avantage et un inconvénient.
- L'avantage c'est la validation Zod qui permet de réutiliser cette logique (en exportant le schéma). C'est simple à maintenir et c'est lisible.
- Le désavantage, c'est qu'en cas d'erreur (si le schéma n'est pas respecté), le message d'erreur de Zod ne sera pas affiché côté client.
ZodError: [
{
"code": "too_small",
"minimum": 3,
"type": "string",
"inclusive": true,
"exact": false,
"message": "Le nom doit contenir au moins 3 caractères",
"path": []
}
]
at Object.get error [as error] (file:///Users/dev/dev/remix-forms/node_modules/zod/lib/index.mjs:538:31)
at ZodString.parse (file:///Users/dev/dev/remix-forms/node_modules/zod/lib/index.mjs:638:22)
at action2 (file:///Users/dev/dev/remix-forms/app/routes/_index.tsx:11:32)
```
Nous avons deux solutions, chacune avec des avantages et des inconvénients.
##### Implémenter nous-même la gestion des erreurs Zod en utilisant 'safeParse'
Cette solution possède un avantage : ne pas installer une librairie supplémentaire.
L'inconvénient, c'est que plus le schéma devient complexe, plus la gestion des erreurs devient complexe
##### Utiliser la librairie Conform pour gérer les erreurs
Cette [librairie](https://conform.guide/) a été conçue spécifiquement pour ce cas d'utilisation (validation de formulaires avec Remix et Zod)
L'avantage principal, c'est que même avec un schéma complexe, le développeur n'aura pas plus de mal à implémenter le formulaire.
Le désavantage, c'est qu'il faut installer une nouvelle librairie. Ça peut être un problème si vous ne voulez pas davantage ralentir votre webapp avec du JS.
Cet article est biaisé, car il parle de l'intégration de Conform dans votre application Remix.
#### Validation des données de formulaire avec Conform
Nous avons besoin d'installer deux librairies pour utiliser Conform.
- `@conform-to/react` pour utiliser son puissant hook `useForm`, valider les données côté client et afficher les erreurs
- `@conform-to/zod` pour valider les données côté serveur et côté client avec Zod. Sans déclencher d'erreur en cas de non-validation.
```bash
npm install @conform-to/react @conform-to/zod
Nous allons implémenter Conform en trois étapes. Modifier le schéma Zod (et l'adapter à Conform), ajouter la logique côté client, puis ajouter la logique côté serveur.
Adapter le schéma Zod à Conform
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
message: 'Le nom doit contenir au moins 3 caractères',
});
const Schema = z.object({
name: nameSchema,
});
Bien que ce formulaire ne possède qu'une propriété pour l'instant, Conform s'attend à recevoir un FormData
en paramètres. Il le transforme ensuite en objet. Nous avons déclaré un nouveau Schema
qui est un z.object
, contenant une propriété name
(qui utilise le schéma que nous avons défini toute à l'heure).
Ajouter 'useForm' côté client
Conform utilise le schéma Zod pour déterminer les champs de notre formulaire. Notre Schema
possède un champ (name
). Nous utilisons le hook useForm
pour récupérer les props
de notre formulaire et de chacun de nos input
(les fields
).
import { getFormProps, getInputProps, useForm } from '@conform-to/react';
import { getZodConstraint, parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
message: 'Le nom doit contenir au moins 3 caractères',
});
const Schema = z.object({
name: nameSchema,
});
export default function Index() {
const [form, fields] = useForm({
constraint: getZodConstraint(Schema),
onValidate({ formData }) {
return parseWithZod(formData, {
schema: Schema,
});
},
lastResult: undefined,
});
return (
<form {...getFormProps(form)} method='POST'>
<input {...getInputProps(fields.name, { type: 'text' })} />
<div>{fields.name.errors}</div>
<button>S'inscrire</button>
</form>
);
}
On peut constater qu'on ne définit aucun props
sur nos input
. C'est Conform qui gère leur valeur, leur ID, le focus en cas d'erreur et plein d'autres fonctionnalités lié au progressive enhancement pour offrir à nos utilisateurs une expérience optimale.
Constatez aussi la méthode parseWithZod
, utilisée pour valider les données du formData
côté client, dans la méthode onValidate
. C'est exactement ce que nous avons fait tout à l'heure côté serveur ! Le FormData
vient du client, et n'est envoyé au serveur qu'après la soumission (et la première validation par Conform et Zod)
Validation des données côté serveur avec Conform et 'parseWithZod'
Il nous manque la validation la plus importante. Celle qui est effectuée côté serveur. C'est dans notre action qu'on va vérifier les informations fournies par l'utilisateur. (Est-ce que l'utilisateur existe déjà ? A-t-il validé son mot de passe ? Ces exemples sont utilisés dans toutes les applications, même si notre implémentation ne le reflète pas)
import { ActionFunctionArgs, json } from '@remix-run/node';
import { z } from 'zod';
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
message: 'Le nom doit contenir au moins 3 caractères',
});
const Schema = z.object({
name: nameSchema,
});
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const submission = await parseWithZod(formData, {
schema: Schema,
});
if (submission.status !== 'success') {
return json({ result: submission.reply() });
}
return json({
result: submission.reply({
resetForm: true,
}),
});
};
Nous devons toujours récupérer les valeurs du formulaire avec await request.formData()
, sauf que cette fois on laisse Conform se charger de tout, grâce à la méthode parseWithZod
. Cette méthode prend le formData
et notre schéma Zod en deuxième argument.
Il nous renvoie ensuite un objet submission
, qui possède l'un des deux status : error
et success
.
- Si le statut
error
est renvoyé, alors nous renvoyons une réponse Conform au client. Nous devons ensuite la passer comme argument dans le hookuseForm
(clélastResult
) pour qu'il puisse faire le lien entre la soumission qui a échouée côté serveur, et les inputs côté client. - Si le statut
success
est renvoyé, cela signifie que les données sont conformes au schéma. Il n'y a rien de plus à faire. On a implémenté la validation côté serveur.
Voici le composant intégral (avec le lastResult
)
import { getFormProps, getInputProps, useForm } from '@conform-to/react';
import { getZodConstraint, parseWithZod } from '@conform-to/zod';
import { ActionFunctionArgs, json } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
import { z } from 'zod';
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
message: 'Le nom doit contenir au moins 3 caractères',
});
const Schema = z.object({
name: nameSchema,
});
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const submission = await parseWithZod(formData, {
schema: Schema,
});
if (submission.status !== 'success') {
return json({ result: submission.reply() });
}
return json({
result: submission.reply({
resetForm: true,
}),
});
};
export default function Index() {
const actionData = useActionData<typeof action>();
const [form, fields] = useForm({
constraint: getZodConstraint(Schema),
onValidate({ formData }) {
return parseWithZod(formData, {
schema: Schema,
});
},
lastResult: actionData?.result,
});
return (
<form {...getFormProps(form)} method='POST'>
<input {...getInputProps(fields.name, { type: 'text' })} />
<div>{fields.name.errors}</div>
<button>S'inscrire</button>
</form>
);
}
Conclusion
En explorant l'implémentation d'un formulaire étape par étape avec Remix, nous avons découvert l'importance cruciale de l'expérience utilisateur (UX) dans la gestion des erreurs. Commencer par les fondamentaux nous a permis de comprendre comment récupérer et valider les données de formulaire côté serveur.
L'utilisation de Zod nous a permis de consolider la validation des données, en ajoutant des messages d'erreurs clairs, tout en restant plus simple à maintenir qu'une gestion d'erreur "manuelle".
Utiliser Conform nous a permi d'optimiser l'expérience utilisateur grâce à une validation efficace et des retours visuels, pour prévenir les utilisateurs de leurs erreurs éventuelles avec des messages d'erreurs de qualité.
Je vous encourage à tester Remix avec ces outils ! Depuis que je les ai découvert, je ne m'en sépare plus.
Votre feedback est précieux — n'hésitez pas à partager vos expériences, questions ou suggestions sur Twitter ou sur LinkedIn si ça vous a été utile. Ensemble, améliorons l'UX de nos formulaires et offrons une expérience agréable à nos utilisateurs.
Top comments (0)