Comment créer une API avec plusieurs actions dans Remix ?

Il y a quelques semaines, j'ai découvert un nouveau concept Typescript en lisant un article intéressant sur les Discriminated Union Types (merci encore Dimitri 😉).
Il m'a permis d'améliorer le code de mes applications Remix. Notamment sur la partie serveur, avec les actions.
Pourquoi utiliser le Discriminated Union Type ?
Je l'utilise parce qu'il me permet d'écrire une logique une seule fois. Au lieu de devoir écrire plusieurs fois la même logique de validation, pour des actions mineures comme la suppression, l'édition et la création d'une donnée, toute cette logique se retrouve dans le même fichier (et non pas dans 3-4 fichiers différents).
Comment créer une route API
Pour mieux comprendre le routing dans Remix, je vous conseille l'article sur les 6 types de routes à connaître dans Remix.
Il nous suffit de créer un fichier dans le dossier app/routes
, que nous allons nommer et qui prendra l'extension tsx
. Prenons par exemple une route que nous allons appeler actions
, qui sera accessible à l'URL http://localhost:3000/actions
.
Ensuite, nous avons besoin d'exporter une méthode nommée action (qui contient la logique serveur) :
1export const action = async ({ request }: ActionFunctionArgs) => {2return null;
La fonction action sera exécutée à chaque requête API de type POST, PUT, DELETE ... Toutes les méthodes HTTP sauf le GET.
Maintenant, nous allons rajouter un peu de logique, avec plusieurs actions possibles.
Comment déterminer l'action à exécuter ?
Dans cet exemple, nous souhaitons appeler cette route pour ajouter ou supprimer un utilisateur. Nous avons donc 2 mutations (ou actions) différentes dans la même méthode action.
Pour faire simple, nous allons soumettre une valeur dans le formulaire qui va changer en fonction de l'action à effectuer. Nous allons ensuite utiliser un switch pour déterminer laquelle des deux actions doit être exécutée.
C'est la valeur de la variable intent qui détermine quelle logique exécuter.
1const createUser = async () => {2}3const deleteUser = async () => {4}56export const action = async ({ request }: ActionFunctionArgs) => {7const intent : 'create' | 'delete' = 'create';8switch (intent) {9case 'create': {10return await createUser()11}12case 'delete': {13return await deleteUser()14}15}16};
Cependant, c'est le composant React côté client qui va déterminer la valeur de la constante nommée intent. (Nous allons supprimer la déclaration de la variable intent dans la méthode action).
Ajoutons la logique côté client, en exportant par défaut un composant React (je te conseille cet article dans lequel on implémente un formulaire full typesafe avec Remix :
1import type { ActionFunctionArgs } from "@remix-run/node";2import { Form, json } from "@remix-run/react";34const createUser = async () => { };5const deleteUser = async () => { };67export const action = async ({ request }: ActionFunctionArgs) => {8const formData = await request.formData();910const intent = formData.get('intent') as 'create' | 'delete';11switch (intent) {12case "create": {13return await createUser();14}15case "delete": {16return await deleteUser();17}18}19};2021export default function User() {22return (23<Form method="POST">24<input type="text" name="firstname" />25<input type="text" name="lastname" />26<input type="hidden" name="userId" value="123" />27<button name="intent" value="create" type="submit">28Create29</button>30<button name="intent" value="edit" type="submit">31Edit32</button>33<button name="intent" value="delete" type="submit">34Delete35</button>36</Form>37);38}
Mais je comprend pas, il est où ton Discriminated Union Type ?
Effectivement, nous y venons. Nous avons codé toute la logique serveur et client pour nous permettre d'effectuer deux mutations au sein du même composant : créer et supprimer un utilisateur.
Mais nous n'utilisons pas encore de Discriminated Union Type. On pourrait définir des types avec Typescript directement dans notre action, mais ça ne serait pas vraiment typesafe. Pour être sûr d'avoir des données typées, nous avons besoin d'utiliser la librairie Zod pour valider les données de formulaire. Je vous recommende également la librairie Conform pour améliorer l'expérience de vos utilisateurs.
Pour maîtriser deux ces librairies avec Remix, regardez cette vidéo
Utiliser le Discriminated Union Type avec Zod et Conform
Nous avons besoin d'installer trois librairies pour avoir des données typées (grâce à Zod), discriminer la mutation (grâce à Typescript) et améliorer l'UX du formulaire (grâce à Conform).
1npm install zod @conform-to/zod @conform-to/react
Ensuite, nous devons définir deux schémas Zod :
- Un schéma pour créer l'utilisateur (CreateAction)
- Un schéma pour supprimer l'utilisateur (DeleteAction)
Chacun de ces schémas possède une clé en commun : la propriété intent, qui nous permettra de discriminer le schéma (savoir lequel des deux représente la donnée soumise par le formulaire)
Le code ressemble maintenant à ça :
1import { parseWithZod } from "@conform-to/zod";2import type { ActionFunctionArgs } from "@remix-run/node";3import { Form, json } from "@remix-run/react";4import { z } from "zod";56const createUser = async () => { };7const deleteUser = async () => { };89const DeleteAction = z.object({10userId: z.string({11required_error: "User ID is required",12}),13intent: z.literal("delete"),14});1516const CreateAction = z.object({17firstname: z.string({ required_error: "Firstname is required" }),18intent: z.literal("create"),19});2021const Actions = z.union([DeleteAction, CreateAction]);2223export const action = async ({ request }: ActionFunctionArgs) => {24const formData = await request.formData();25const submission = parseWithZod(formData, {26schema: Actions,27});2829if (submission.status !== "success") {30return json({ result: submission.reply() });31}32const { intent } = submission.value;33switch (intent) {34case "create": {35return await createUser();36}37case "delete": {38return await deleteUser();39}40}41};4243export default function User() {44return (45<Form method="POST">46<input type="text" name="firstname" />47<input type="text" name="lastname" />48<input type="hidden" name="userId" value="123" />49<button name="intent" value="create" type="submit">50Create51</button>52<button name="intent" value="edit" type="submit">53Edit54</button>55<button name="intent" value="delete" type="submit">56Delete57</button>58</Form>59);60}
Ce qu'il faut regarder, c'est la déclaration du schéma Actions :
const Actions = z.union([DeleteAction, CreateAction]);
Nous utilisons z.union, pour définir nos deux actions différentes en informant Zod qu'elles pourront être discriminées par la propriété intent.
Maintenant, si l'utilisateur soumet le formulaire avec l'intention de créer un utilisateur, Zod va valider les propriétés firstname, lastname et intent="create".
Si l'utilisateur a l'intention de supprimer l'utilisateur, Zod va valider la propriété intent="delete", et ne va pas causer d'erreur si les propriétés firstname et lastname sont absentes.
Qu'en as-tu pensé ? N'hésite pas à donner ton avis sur LinkedIn ou la chaîne YouTube.