Retour aux articles

Implémente ton premier formulaire avec Remix (full typesafe, UX friendly)

Valider des formulaires avec Zod et Conform
15 minutes de lecture

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 RemixHeader Icon

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.

app/routes/_index.tsx

_10
export default function Index() {
_10
return (
_10
<form>
_10
<input type='text' />
_10
<button>S&apos;inscrire</button>
_10
</form>
_10
);
_10
}

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'Header Icon

Il manque des attributs à notre formulaire:

  • la méthode POST de notre formulaire JSX
  • la propriété name à notre input (qui prend également la valeur name
app/routes/_index.tsx

_10
export default function Index() {
_10
return (
_10
<form method='POST'>
_10
<input type='text' name='name' />
_10
<button type='submit'>S&apos;inscrire</button>
_10
</form>
_10
);
_10
}

Essayons de soumettre notre formulaire !

soumission de formulaire échouée

405 Method Not AllowedHeader Icon

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.

Lifecycle Remix - Loader, Component, Action

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'Header Icon

La fonction action est une convention de Remix. Ajoutons cette méthode pour autoriser les requêtes POST dans notre route.

app/routes/_index.tsx

_13
import type { ActionFunctionArgs } from '@remix-run/node';
_13
export const action = async ({ request }: ActionFunctionArgs) => {
_13
return null;
_13
};
_13
_13
export default function Index() {
_13
return (
_13
<form method='POST'>
_13
<input type='text' name='name' />
_13
<button type='submit'>S&apos;inscrire</button>
_13
</form>
_13
);
_13
}

L'erreur est réparée. On peut passer à la suite.

Validation d'un formulaire côté serveurHeader Icon

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é serveurHeader Icon

Avant de valider les données côté serveur, nous devons les récupérer. Voici la solution.

app/routes/_index.tsx

_15
import type { ActionFunctionArgs } from '@remix-run/node';
_15
export const action = async ({ request }: ActionFunctionArgs) => {
_15
const formData = await request.formData();
_15
const name = formData.get('name');
_15
return null;
_15
};
_15
_15
export default function Index() {
_15
return (
_15
<form method='POST'>
_15
<input type='text' name='name' />
_15
<button>S&apos;inscrire</button>
_15
</form>
_15
);
_15
}

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 props onSubmit dans notre form.

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é serveurHeader Icon

Pourquoi valider nos données de formulaire ?Header Icon

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 formulaireHeader Icon

À 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.
app/routes/_index.tsx

_30
import type { ActionFunctionArgs } from '@remix-run/node';
_30
import { useActionData } from '@remix-run/react';
_30
export const action = async ({ request }: ActionFunctionArgs) => {
_30
const formData = await request.formData();
_30
const name = formData.get('name');
_30
_30
if (typeof name !== 'string') {
_30
return {
_30
message: 'Le nom est requis',
_30
};
_30
}
_30
_30
if (name.length < 3) {
_30
return {
_30
message: 'Le nom doit contenir au moins 3 caractères',
_30
};
_30
}
_30
return { message: 'Inscription réussie' };
_30
};
_30
_30
export default function Index() {
_30
const actionData = useActionData<typeof action>();
_30
return (
_30
<form method='POST'>
_30
<input type='text' name='name' />
_30
{actionData?.message ? <p>{actionData.message}</p> : null}
_30
<button>S&apos;inscrire</button>
_30
</form>
_30
);
_30
}

Soumission d'un formulaire avec retour utilisateur sous forme de message

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 ZodHeader Icon

Pour pouvoir utiliser Zod, (c'est une librairie Javascript de validation de données), nous avons besoin de l'installer dans notre dossier projet.

Terminal

_10
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).


_10
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
_10
message: 'Le nom doit contenir au moins 3 caractères',
_10
});

Ensuite, nous allons utiliser la méthode .parse($data) pour déclencher la validation de notre donnée $data (ici, ce sera notre name).


_10
const parsedName = nameSchema.parse(name);

Voici l'implémentation complète.

app/routes/_index.tsx

_24
import type { ActionFunctionArgs } from '@remix-run/node';
_24
import { useActionData } from '@remix-run/react';
_24
import { z } from 'zod';
_24
_24
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
_24
message: 'Le nom doit contenir au moins 3 caractères',
_24
});
_24
export const action = async ({ request }: ActionFunctionArgs) => {
_24
const formData = await request.formData();
_24
const name = formData.get('name');
_24
const parsedName = nameSchema.parse(name);
_24
return { message: 'Inscription réussie' };
_24
};
_24
_24
export default function Index() {
_24
const actionData = useActionData<typeof action>();
_24
return (
_24
<form method='POST'>
_24
<input type='text' name='name' />
_24
{actionData?.message ? <p>{actionData.message}</p> : null}
_24
<button>S&apos;inscrire</button>
_24
</form>
_24
);
_24
}

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.

La validation Zod échoue, et l'erreur n'est pas attrapée.


_14
ZodError: [
_14
{
_14
"code": "too_small",
_14
"minimum": 3,
_14
"type": "string",
_14
"inclusive": true,
_14
"exact": false,
_14
"message": "Le nom doit contenir au moins 3 caractères",
_14
"path": []
_14
}
_14
]
_14
at Object.get error [as error] (file:///Users/dev/dev/remix-forms/node_modules/zod/lib/index.mjs:538:31)
_14
at ZodString.parse (file:///Users/dev/dev/remix-forms/node_modules/zod/lib/index.mjs:638:22)
_14
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'Header Icon

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 erreursHeader Icon

Cette librairie 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 ConformHeader Icon

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.
Terminal

_10
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 à ConformHeader Icon

_10
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
_10
message: 'Le nom doit contenir au moins 3 caractères',
_10
});
_10
_10
const Schema = z.object({
_10
name: nameSchema,
_10
});

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é clientHeader Icon

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).

app/routes/_index.tsx

_30
import { getFormProps, getInputProps, useForm } from '@conform-to/react';
_30
import { getZodConstraint, parseWithZod } from '@conform-to/zod';
_30
import { z } from 'zod';
_30
_30
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
_30
message: 'Le nom doit contenir au moins 3 caractères',
_30
});
_30
_30
const Schema = z.object({
_30
name: nameSchema,
_30
});
_30
_30
export default function Index() {
_30
const [form, fields] = useForm({
_30
constraint: getZodConstraint(Schema),
_30
onValidate({ formData }) {
_30
return parseWithZod(formData, {
_30
schema: Schema,
_30
});
_30
},
_30
lastResult: undefined,
_30
});
_30
return (
_30
<form {...getFormProps(form)} method='POST'>
_30
<input {...getInputProps(fields.name, { type: 'text' })} />
_30
<div>{fields.name.errors}</div>
_30
<button>S&apos;inscrire</button>
_30
</form>
_30
);
_30
}

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'Header Icon

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)


_26
import { ActionFunctionArgs, json } from '@remix-run/node';
_26
import { z } from 'zod';
_26
_26
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
_26
message: 'Le nom doit contenir au moins 3 caractères',
_26
});
_26
_26
const Schema = z.object({
_26
name: nameSchema,
_26
});
_26
_26
export const action = async ({ request }: ActionFunctionArgs) => {
_26
const formData = await request.formData();
_26
const submission = await parseWithZod(formData, {
_26
schema: Schema,
_26
});
_26
if (submission.status !== 'success') {
_26
return json({ result: submission.reply() });
_26
}
_26
_26
return json({
_26
result: submission.reply({
_26
resetForm: true,
_26
}),
_26
});
_26
};

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 hook useForm (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)

app/routes/_index.tsx

_49
import { getFormProps, getInputProps, useForm } from '@conform-to/react';
_49
import { getZodConstraint, parseWithZod } from '@conform-to/zod';
_49
import { ActionFunctionArgs, json } from '@remix-run/node';
_49
import { useActionData } from '@remix-run/react';
_49
import { z } from 'zod';
_49
_49
const nameSchema = z.string({ required_error: 'Le nom est requis' }).min(3, {
_49
message: 'Le nom doit contenir au moins 3 caractères',
_49
});
_49
_49
const Schema = z.object({
_49
name: nameSchema,
_49
});
_49
_49
export const action = async ({ request }: ActionFunctionArgs) => {
_49
const formData = await request.formData();
_49
const submission = await parseWithZod(formData, {
_49
schema: Schema,
_49
});
_49
if (submission.status !== 'success') {
_49
return json({ result: submission.reply() });
_49
}
_49
_49
return json({
_49
result: submission.reply({
_49
resetForm: true,
_49
}),
_49
});
_49
};
_49
_49
export default function Index() {
_49
const actionData = useActionData<typeof action>();
_49
const [form, fields] = useForm({
_49
constraint: getZodConstraint(Schema),
_49
onValidate({ formData }) {
_49
return parseWithZod(formData, {
_49
schema: Schema,
_49
});
_49
},
_49
lastResult: actionData?.result,
_49
});
_49
return (
_49
<form {...getFormProps(form)} method='POST'>
_49
<input {...getInputProps(fields.name, { type: 'text' })} />
_49
<div>{fields.name.errors}</div>
_49
<button>S&apos;inscrire</button>
_49
</form>
_49
);
_49
}

ConclusionHeader Icon

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.

Articles similaires
Gère les erreurs avec le framework Remix.js
6 minutes de lectureRemixReactJS

Comment gérer les erreurs avec Remix ? (ErrorBoundary)

Avoir des erreurs Javascript ne fait jamais plaisir. Mais il existe le composant ErrorBoundary). Dans ce guide, tu vas découvrir comment afficher un composant d'erreur personnalisé en pour protéger toutes les pages de ton application.

Rejoins la
newsletter