Retour aux articles

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

Améliore ton code avec ce concept Typescript méconnu
5 minutes de lecture

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

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

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

app/routes/actions.tsx

_10
export const action = async ({ request }: ActionFunctionArgs) => {
_10
return 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 ?Header Icon

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.

app/routes/actions.tsx

_16
const createUser = async () => {
_16
}
_16
const deleteUser = async () => {
_16
}
_16
_16
export const action = async ({ request }: ActionFunctionArgs) => {
_16
const intent : 'create' | 'delete' = 'create';
_16
switch (intent) {
_16
case 'create': {
_16
return await createUser()
_16
}
_16
case 'delete': {
_16
return await deleteUser()
_16
}
_16
}
_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 :

app/routes/actions.tsx

_38
import type { ActionFunctionArgs } from "@remix-run/node";
_38
import { Form, json } from "@remix-run/react";
_38
_38
const createUser = async () => { };
_38
const deleteUser = async () => { };
_38
_38
export const action = async ({ request }: ActionFunctionArgs) => {
_38
const formData = await request.formData();
_38
_38
const intent = formData.get('intent') as 'create' | 'delete';
_38
switch (intent) {
_38
case "create": {
_38
return await createUser();
_38
}
_38
case "delete": {
_38
return await deleteUser();
_38
}
_38
}
_38
};
_38
_38
export default function User() {
_38
return (
_38
<Form method="POST">
_38
<input type="text" name="firstname" />
_38
<input type="text" name="lastname" />
_38
<input type="hidden" name="userId" value="123" />
_38
<button name="intent" value="create" type="submit">
_38
Create
_38
</button>
_38
<button name="intent" value="edit" type="submit">
_38
Edit
_38
</button>
_38
<button name="intent" value="delete" type="submit">
_38
Delete
_38
</button>
_38
</Form>
_38
);
_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 ConformHeader Icon

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


_10
npm 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 :

app/routes/actions.tsx

_60
import { parseWithZod } from "@conform-to/zod";
_60
import type { ActionFunctionArgs } from "@remix-run/node";
_60
import { Form, json } from "@remix-run/react";
_60
import { z } from "zod";
_60
_60
const createUser = async () => { };
_60
const deleteUser = async () => { };
_60
_60
const DeleteAction = z.object({
_60
userId: z.string({
_60
required_error: "User ID is required",
_60
}),
_60
intent: z.literal("delete"),
_60
});
_60
_60
const CreateAction = z.object({
_60
firstname: z.string({ required_error: "Firstname is required" }),
_60
intent: z.literal("create"),
_60
});
_60
_60
const Actions = z.union([DeleteAction, CreateAction]);
_60
_60
export const action = async ({ request }: ActionFunctionArgs) => {
_60
const formData = await request.formData();
_60
const submission = parseWithZod(formData, {
_60
schema: Actions,
_60
});
_60
_60
if (submission.status !== "success") {
_60
return json({ result: submission.reply() });
_60
}
_60
const { intent } = submission.value;
_60
switch (intent) {
_60
case "create": {
_60
return await createUser();
_60
}
_60
case "delete": {
_60
return await deleteUser();
_60
}
_60
}
_60
};
_60
_60
export default function User() {
_60
return (
_60
<Form method="POST">
_60
<input type="text" name="firstname" />
_60
<input type="text" name="lastname" />
_60
<input type="hidden" name="userId" value="123" />
_60
<button name="intent" value="create" type="submit">
_60
Create
_60
</button>
_60
<button name="intent" value="edit" type="submit">
_60
Edit
_60
</button>
_60
<button name="intent" value="delete" type="submit">
_60
Delete
_60
</button>
_60
</Form>
_60
);
_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.

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