Aller au contenu

Server Actions et formulaires

Qu'est-ce qu'une Server Action

En React pur, pour interagir avec une base de données (ex: un formulaire pour l'ajout d'un produit), il faut appeler un API (souvent en Express ou PHP). Les actions serveurs élimine le besoin d'un API distinct en permettant l'appel de fonctions asynchrone entre la portion client et serveur de votre application.

"use server"

Une action serveur doit avoir "use server" au début du fichier :

app/actions/produit.actions.ts
'use server';

import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

export async function creerProduit(
  prevState: { erreur: string } | undefined,
  formData: FormData,
) {
  const nom = formData.get('nom') as string;
  const description = formData.get('description') as string;
  const prix = parseFloat(formData.get('prix') as string);
  const categorieId = parseInt(formData.get('categorieId') as string);

  // Validation
  if (!nom || !prix || !categorieId) {
    return { erreur: 'Tous les champs obligatoires doivent être remplis.' };
  }

  await prisma.produit.create({
    data: { nom, description, prix, categorieId },
  });

  revalidatePath('/produits');
}

export async function supprimerProduit(id: number) {
  await prisma.produit.delete({
    where: { id },
  });

  revalidatePath('/produits');
}

Formulaires avec Server Actions

On peut utiliser une actions serveur directement dans l'attribut action d'un formulaire HTML :

app/produits/nouveau/page.tsx
import { creerProduit } from '@/app/actions/produit.actions';
import { useActionState } from 'react';

export default function NouveauProduit() {
  const [state, formAction, pending] = useActionState(creerProduit, undefined);
  return (
    <main>
      <h1>Ajouter un produit</h1>

      <form action={formAction}>
        <div>
          <label htmlFor="nom">Nom :</label>
          <input type="text" id="nom" name="nom" required />
        </div>

        <div>
          <label htmlFor="description">Description :</label>
          <textarea id="description" name="description" />
        </div>

        <div>
          <label htmlFor="prix">Prix :</label>
          <input type="number" id="prix" name="prix" step="0.01" required />
        </div>

        <div>
          <label htmlFor="categorieId">Catégorie (ID) :</label>
          <input type="number" id="categorieId" name="categorieId" required />
        </div>

        {state?.erreur && (
          <p
            aria-live="polite"
            className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-400"
          >
            {state.erreur}
          </p>
        )}

        <button type="submit" disabled={pending}>
          {pending ? 'Ajout en cours…' : 'Ajouter'}
        </button>
      </form>
    </main>
  );
}

Lorsque le formulaire est soumis, la fonction creerProduit est appelée sur le serveur avec les données du formulaire en FormData.

Utiliser une Server Action avec bind

Si vous avez des variables à passer à l'action serveur sans l'ajouter comme champ du formulaire, il faut utiliser .bind() :

app/produits/page.tsx
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { supprimerProduit } from "@/app/actions/produit.actions";

export default async function ProduitsPage() {
  const produits = await prisma.produit.findMany({
    include: { categorie: true },
  });

  return (
    <main>
      <h1>Liste des produits</h1>
      <Link href="/produits/nouveau">Ajouter un produit</Link>

      <table>
        <thead>
          <tr>
            <th>Nom</th>
            <th>Prix</th>
            <th>Catégorie</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {produits.map((produit) => (
            <tr key={produit.id}>
              <td>{produit.nom}</td>
              <td>{produit.prix} $</td>
              <td>{produit.categorie.nom}</td>
              <td>
                <form action={supprimerProduit.bind(null, produit.id)}>
                  <button type="submit">Supprimer</button>
                </form>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}

Validation de formulaires

Même si la validation est faite du côté client, pour des raisons de sécurité et d'intégrité il faut valider toutes les données du formulaire dans l'action serveur.

Validation dans une Server Action
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

interface ResultatAction {
  erreur?: string;
  succes?: boolean;
}

export async function creerProduit(
  etatPrecedent: ResultatAction,
  formData: FormData
): Promise<ResultatAction> {
  const nom = formData.get("nom") as string;
  const prix = parseFloat(formData.get("prix") as string);

  // Validation
  if (!nom || nom.trim().length === 0) {
    return { erreur: "Le nom est requis." };
  }

  if (isNaN(prix) || prix <= 0) {
    return { erreur: "Le prix doit être un nombre positif." };
  }

  await prisma.produit.create({
    data: { nom, prix, categorieId: 1 },
  });

  revalidatePath("/produits");
  return { succes: true };
}

Pour utiliser cette version avec validation, le formulaire doit être un Client Component utilisant le hook useActionState :

Formulaire avec validation (Client Component)
"use client";

import { useActionState } from "react";
import { creerProduit } from "@/app/actions/produit.actions";

export default function FormulaireProduit() {
  const [etat, action, enAttente] = useActionState(creerProduit, {});

  return (
    <form action={action}>
      <div>
        <label htmlFor="nom">Nom :</label>
        <input type="text" id="nom" name="nom" required />
      </div>

      <div>
        <label htmlFor="prix">Prix :</label>
        <input type="number" id="prix" name="prix" step="0.01" required />
      </div>

      {etat.erreur && <p style={{ color: "red" }}>{etat.erreur}</p>}
      {etat.succes && <p style={{ color: "green" }}>Produit ajouté!</p>}

      <button type="submit" disabled={enAttente}>
        {enAttente ? "Ajout en cours..." : "Ajouter"}
      </button>
    </form>
  );
}

Revalidation des données

Après un changement dans les données (création, modification, suppression), il faut dire à Next.js de rafraîchir les données affichées en utilisant revalidatePath :

Revalidation
import { revalidatePath } from "next/cache";

// Revalider une page spécifique
revalidatePath("/produits");

// Revalider toutes les pages
revalidatePath("/", "layout");

Sans revalidatePath, les pages afficheront les données en cache sans les dernières modifications...