Développe et valide un formulaire avec Remix
Tags: react, remix, zod, typescript
Published: 2024-02-18 10:06:07

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](https://remix.run). D'abord sans librairie externe. Puis nous allons l'améliorer peu à peu en ajoutant [Zod](https://zod.dev/), puis [Conform](https://conform.guide/).

Cet article existe également au [format vidéo](https://youtu.be/_-ZOGj80TMw) sur YouTube.

{% youtube _-ZOGj80TMw %}
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*.

```tsx
export default function Index() {
	return (
		<form>
			<input type='text' />
			<button>S&apos;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](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input) seront visibles dans l'URL lors de la soumission (sous forme de [paramètres d'URL](https://developer.mozilla.org/fr/docs/Web/API/URLSearchParams)). De plus, l'`input` ne possède pas de propriété [`name`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#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](https://developer.mozilla.org/fr/docs/Web/HTTP/Methods/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` à notre `input` (qui prend également la valeur *name*


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

Essayons de soumettre notre formulaire !

![soumission de formulaire échouée](https://algomax-public.s3.eu-west-3.amazonaws.com/failed_submission_402df7004f.gif)

### 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](https://remix.run/docs/en/main/guides/api-routes#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](https://remix.run/docs/en/main/guides/api-routes#api-routes) de la doc.

![Lifecycle Remix - Loader, Component, Action](https://algomax-public.s3.eu-west-3.amazonaws.com/loader_action_component_a52cbe146c.png)


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](https://fr.react.dev/) 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](https://remix.run/docs/en/main/route/action) est une convention de Remix. Ajoutons cette méthode pour autoriser les requêtes `POST` dans notre route.

```tsx
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&apos;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.

```tsx
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&apos;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](https://developer.mozilla.org/en-US/docs/Web/API/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é 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](https://www.npmjs.com/package/tiny-invariant) 
- En utilisant [Zod](https://zod.dev)

> 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](https://remix.run/docs/en/main/hooks/use-action-data) 
- Nous l'affichons conditionnellement à l'utilisateur.


```tsx
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&apos;inscrire</button>
		</form>
	);
}
```

![Soumission d'un formulaire avec retour utilisateur sous forme de message](https://algomax-public.s3.eu-west-3.amazonaws.com/successful_feedback_4f841a2ca1.gif)

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](https://algomax.fr/blog?categories=nestjs))

#### Validation de nos données de formulaire avec Zod

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

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

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

```tsx
const parsedName = nameSchema.parse(name);
```

Voici l'implémentation complète.

```tsx
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&apos;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.

![La validation Zod échoue, et l'erreur n'est pas attrapée.](https://algomax-public.s3.eu-west-3.amazonaws.com/failed_error_handling_97c8579ae5.gif)

```js
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

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

```tsx
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&apos;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](https://remix.run/docs/en/main/discussion/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)

```ts
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 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`)

```tsx
 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&apos;inscrire</button>
		</form>
	);
}
 ```
### Conclusion

En explorant l'implémentation d'un formulaire étape par étape avec [Remix](https://remix.run), 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](https://zod.dev) 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](https://conform.guide) 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](https://twitter.com/varkoffs) ou sur [LinkedIn](https://www.linkedin.com/in/virgile-rietsch/) si ça vous a été utile. Ensemble, améliorons l'UX de nos formulaires et offrons une expérience agréable à nos utilisateurs.