DEV Community

Cover image for Développe et valide un formulaire avec Remix
Virgile RIETSCH
Virgile RIETSCH

Posted on • Originally published at algomax.fr

Développe et valide un formulaire avec Remix

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&apos;inscrire</button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

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 à notre input (qui prend également la valeur name
export default function Index() {
    return (
        <form method='POST'>
            <input type='text' name='name' />
            <button type='submit'>S&apos;inscrire</button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

Essayons de soumettre notre formulaire !

soumission de formulaire échouée

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.

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'

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&apos;inscrire</button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

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&apos;inscrire</button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

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é 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&apos;inscrire</button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

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 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
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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&apos;inscrire</button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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&apos;inscrire</button>
        </form>
    );
}
Enter fullscreen mode Exit fullscreen mode

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,
        }),
    });
};
Enter fullscreen mode Exit fullscreen mode

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)

 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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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)