Cours Next.js · Module 9: Intégrations et fonctions avancées

Server Actions - patterns avancés

11 min de lecture
Dans cette leçon7

Tu cliques sur "Ajouter une tâche" dans le panneau de la Métropole Quantum 2150 et... rien. La liste reste figée parce que le formulaire attend le serveur. L'utilisateur clique une deuxième fois et un doublon apparaît. Tu connais déjà les bases des Server Actions : une fonction avec la directive 'use server' appelée directement depuis un composant React, sans endpoint d'API séparé. Tu vas maintenant découvrir les patterns qui distinguent un prototype d'une application fiable.

Optimistic Updates avec useOptimistic

Le hook useOptimistic de React 19 affiche le résultat d'une opération avant que le serveur ne la confirme. Il reçoit l'état actuel et une fonction qui le combine avec un changement, et renvoie l'état à afficher ainsi qu'une fonction qui ajoute un changement. Quand l'action se termine, React revient aux vraies données, donc en cas d'échec l'état revient tout seul à sa valeur précédente.

Pattern de base

On décrit d'abord une tâche avec l'interface Task (id, titre, indicateur de fin), puis on branche useOptimistic sur l'action du formulaire d'un composant client :

1// app/tasks/TaskList.tsx
2'use client';
3
4import { useOptimistic } from 'react';
5import { addTask, toggleTask } from './actions';
6
7interface Task {
8  id: string;
9  title: string;
10  completed: boolean;
11}
12
13export default function TaskList({ tasks }: { tasks: Task[] }) {
14  const [optimisticTasks, addOptimisticTask] = useOptimistic(
15    tasks,
16    (state: Task[], newTask: Task) => [...state, newTask]
17  );
18
19  async function handleAddTask(formData: FormData) {
20    const title = formData.get('title') as string;
21
22    // Mise à jour instantanée de l'UI
23    addOptimisticTask({
24      id: 'temp-' + Date.now(),
25      title,
26      completed: false,
27    });
28
29    // Appel de la Server Action
30    await addTask(formData);
31  }
32
33  return (
34    <div>
35      <form action={handleAddTask}>
36        <input name="title" placeholder="Nouvelle tâche..." />
37        <button type="submit">Ajouter</button>
38      </form>
39
40      <ul>
41        {optimisticTasks.map((task) => (
42          <li key={task.id}>{task.title}</li>
43        ))}
44      </ul>
45    </div>
46  );
47}

La nouvelle tâche apparaît immédiatement avec un id temporaire, et c'est seulement ensuite qu'on appelle addTask. Le code ne modifie PAS la prop tasks : la vraie liste arrivera du serveur et remplacera la version temporaire. La fonction renvoyée par useOptimistic ne peut être appelée que dans une action ou une transition, c'est pourquoi on l'appelle dans handleAddTask.

Server Action avec toggle optimiste

Côté serveur, dans un fichier avec 'use server', une fonction crée la tâche et l'autre bascule son état :

1// app/tasks/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { db } from '@/lib/db';
6
7export async function addTask(formData: FormData) {
8  const title = formData.get('title') as string;
9
10  await db.tasks.create({
11    data: { title, completed: false },
12  });
13
14  revalidatePath('/tasks');
15}
16
17export async function toggleTask(taskId: string) {
18  const task = await db.tasks.findUnique({ where: { id: taskId } });
19
20  await db.tasks.update({
21    where: { id: taskId },
22    data: { completed: !task?.completed },
23  });
24
25  revalidatePath('/tasks');
26}

Les deux enregistrent les données et appellent revalidatePath('/tasks'), donc la page est régénérée avec une liste fraîche. Tu peux brancher toggleTask sur une case à cocher avec le même pattern optimiste.

Revalidation après une mutation

Après un changement de données, le cache de Next.js garde en mémoire l'ancienne version de la page. Tu le rafraîchis par chemin (revalidatePath) ou par tag (revalidateTag). C'est le complément de l'ISR (Incremental Static Regeneration), c'est-à-dire la génération statique avec mise à jour périodique : l'ISR rafraîchit selon un minuteur, la revalidation à la demande juste après une mutation.

revalidatePath - rafraîchir un chemin

La variante la plus simple reçoit l'adresse d'une page, et le second argument optionnel 'layout' englobe le layout et toutes les pages en dessous :

1// actions/products.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5
6export async function updateProduct(productId: string, formData: FormData) {
7  const name = formData.get('name') as string;
8  const price = parseFloat(formData.get('price') as string);
9
10  await db.products.update({
11    where: { id: productId },
12    data: { name, price },
13  });
14
15  // Rafraîchis la page produit concernée
16  revalidatePath('/products/' + productId);
17
18  // Rafraîchis la liste des produits
19  revalidatePath('/products');
20
21  // Rafraîchis tout le layout
22  revalidatePath('/', 'layout');
23}

La revalidation ne recharge pas les données tout de suite, elle marque seulement le cache comme périmé. Les données fraîches se chargent à la visite suivante.

revalidateTag - rafraîchir par tag

Quand les mêmes données apparaissent sur plusieurs pages, il est plus pratique de les taguer. Ici, les fonctions qui récupèrent les produits sont enveloppées dans unstable_cache avec une liste de tags :

1// lib/data.ts
2import { unstable_cache } from 'next/cache';
3
4export const getProducts = unstable_cache(
5  async () => {
6    return await db.products.findMany();
7  },
8  ['products'],
9  { tags: ['products'] }
10);
11
12export const getProduct = unstable_cache(
13  async (id: string) => {
14    return await db.products.findUnique({ where: { id } });
15  },
16  ['product'],
17  { tags: ['products', 'product-detail'] }
18);

Dans Next.js 16, unstable_cache appartient à l'ancien modèle et est remplacé par la directive 'use cache' avec la fonction cacheTag, que tu découvriras dans la leçon sur le cache. Les tags fonctionnent de la même façon dans les deux modèles.

L'action qui supprime un produit invalide maintenant toutes les données taguées products en un seul appel :

1// actions/products.ts
2'use server';
3
4import { revalidateTag } from 'next/cache';
5
6export async function deleteProduct(productId: string) {
7  await db.products.delete({ where: { id: productId } });
8
9  // Rafraîchis toutes les données taguées 'products'
10  revalidateTag('products', 'max');
11}

Le second argument 'max' est le profil recommandé dans Next.js 16 : l'utilisateur reçoit l'ancienne version pendant que la nouvelle se charge en arrière-plan. L'appel avec le tag seul est obsolète. Si l'utilisateur doit voir sa propre modification immédiatement, utilise updateTag('products') dans la Server Action.

Gestion des erreurs dans les Server Actions

Une exception levée, c'est un écran d'erreur pour l'utilisateur. Mieux vaut renvoyer un résultat structuré avec un champ success, un message error et les erreurs par champ. La validation est confiée à Zod, une bibliothèque qui décrit la forme des données avec un schéma.

Pattern de gestion des erreurs

Le schéma loginSchema vérifie l'email et le mot de passe, l'interface ActionResult décrit le résultat, et le paramètre prevState est exigé par le hook de la section suivante :

1// actions/auth.ts
2'use server';
3
4import { z } from 'zod';
5
6const loginSchema = z.object({
7  email: z.string().email('Email invalide'),
8  password: z.string().min(8, 'Le mot de passe doit contenir au moins 8 caractères'),
9});
10
11interface ActionResult {
12  success: boolean;
13  error?: string;
14  fieldErrors?: Record<string, string[]>;
15}
16
17export async function loginAction(
18  prevState: ActionResult,
19  formData: FormData
20): Promise<ActionResult> {
21  const rawData = {
22    email: formData.get('email'),
23    password: formData.get('password'),
24  };
25
26  // Valide les données
27  const validatedFields = loginSchema.safeParse(rawData);
28
29  if (!validatedFields.success) {
30    return {
31      success: false,
32      fieldErrors: validatedFields.error.flatten().fieldErrors,
33    };
34  }
35
36  try {
37    const user = await authenticateUser(
38      validatedFields.data.email,
39      validatedFields.data.password
40    );
41
42    if (!user) {
43      return { success: false, error: 'Identifiants de connexion invalides' };
44    }
45
46    // Crée la session
47    await createSession(user.id);
48    return { success: true };
49  } catch (error) {
50    return { success: false, error: 'Une erreur serveur est survenue' };
51  }
52}

safeParse ne lève pas d'exception, donc les erreurs de validation reviennent sous forme de fieldErrors, et try/catch transforme les erreurs serveur en message générique. Avec Zod 4, tu écris cela avec l'API plus récente : z.email() et z.flattenError(error).

Composant de formulaire avec useActionState

Le hook useActionState de React 19 reçoit une action et un état initial, et renvoie l'état, une action pour le form et un indicateur isPending :

1// app/login/page.tsx
2'use client';
3
4import { useActionState } from 'react';
5import { loginAction } from './actions';
6
7export default function LoginForm() {
8  const [state, formAction, isPending] = useActionState(loginAction, {
9    success: false,
10  });
11
12  return (
13    <form action={formAction}>
14      <div>
15        <input name="email" type="email" placeholder="Email" />
16        {state.fieldErrors?.email && (
17          <p className="text-red-500">{state.fieldErrors.email[0]}</p>
18        )}
19      </div>
20
21      <div>
22        <input name="password" type="password" placeholder="Mot de passe" />
23        {state.fieldErrors?.password && (
24          <p className="text-red-500">{state.fieldErrors.password[0]}</p>
25        )}
26      </div>
27
28      {state.error && (
29        <div className="bg-red-100 text-red-700 p-3 rounded">
30          {state.error}
31        </div>
32      )}
33
34      <button type="submit" disabled={isPending}>
35        {isPending ? 'Connexion...' : 'Se connecter'}
36      </button>
37    </form>
38  );
39}

Les erreurs s'affichent sous les champs et le bouton est désactivé pendant l'envoi, donc fini les doubles clics. L'action n'a pas changé : React lui passe l'état précédent et les données du formulaire.

Progressive Enhancement

Les Server Actions fonctionnent même sans JavaScript. Un formulaire HTML envoie par défaut ses données avec la méthode POST, donc le flux marche avant même que le navigateur exécute les scripts.

Formulaire avec progressive enhancement

Ce formulaire est un simple Server Component, sans 'use client' et sans aucun hook :

1// app/contact/page.tsx
2import { sendMessage } from './actions';
3
4// Ce formulaire fonctionne même avec JavaScript désactivé !
5export default function ContactForm() {
6  return (
7    <form action={sendMessage}>
8      <input name="name" required placeholder="Prénom" />
9      <input name="email" type="email" required placeholder="Email" />
10      <textarea name="message" required placeholder="Message" />
11      <button type="submit">Envoyer</button>
12    </form>
13  );
14}

Sans JavaScript, le navigateur l'envoie de façon classique, et après l'hydratation Next.js le fait sans rechargement. L'action enregistre le message et redirige :

1// app/contact/actions.ts
2'use server';
3
4import { redirect } from 'next/navigation';
5
6export async function sendMessage(formData: FormData) {
7  const name = formData.get('name') as string;
8  const email = formData.get('email') as string;
9  const message = formData.get('message') as string;
10
11  await db.messages.create({
12    data: { name, email, message },
13  });
14
15  redirect('/contact/success');
16}

redirect fonctionne en levant une exception spéciale, donc appelle-le en dehors d'un bloc try/catch, sinon tu l'avales.

Composition des Server Actions

Dans une seule action, tu peux enchaîner plusieurs étapes, et chacune peut l'arrêter plus tôt en renvoyant une erreur :

1// actions/checkout.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6
7export async function processCheckout(formData: FormData) {
8  const cartId = formData.get('cartId') as string;
9
10  // 1. Validation du panier
11  const cart = await validateCart(cartId);
12  if (!cart.valid) {
13    return { error: 'Le panier est invalide' };
14  }
15
16  // 2. Traitement du paiement
17  const payment = await processPayment({
18    amount: cart.total,
19    method: formData.get('paymentMethod') as string,
20  });
21
22  if (!payment.success) {
23    return { error: 'Le paiement a échoué' };
24  }
25
26  // 3. Création de la commande
27  const order = await createOrder({
28    cartId,
29    paymentId: payment.id,
30    shippingAddress: formData.get('address') as string,
31  });
32
33  // 4. Rafraîchis les données
34  revalidatePath('/orders');
35  revalidatePath('/cart');
36
37  // 5. Redirige vers la confirmation
38  redirect('/orders/' + order.id + '/confirmation');
39}

L'ordre compte : validation, paiement, commande, revalidation, redirection. Chaque Server Action est un endpoint POST public, donc vérifie la session et les permissions dans l'action, pas seulement dans l'UI.

Upload de fichiers avec les Server Actions

Un champ file arrive dans FormData sous forme d'objet File. L'action vérifie sa présence, sa taille et son type, puis l'écrit sur le disque :

1// actions/upload.ts
2'use server';
3
4import { writeFile } from 'fs/promises';
5import path from 'path';
6
7export async function uploadFile(prevState: unknown, formData: FormData) {
8  const file = formData.get('file') as File;
9
10  if (!file || file.size === 0) {
11    return { error: 'Aucun fichier sélectionné' };
12  }
13
14  // Validation du type et de la taille
15  const maxSize = 5 * 1024 * 1024; // 5MB
16  if (file.size > maxSize) {
17    return { error: 'Le fichier est trop volumineux (max 5 Mo)' };
18  }
19
20  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
21  if (!allowedTypes.includes(file.type)) {
22    return { error: 'Type de fichier non autorisé' };
23  }
24
25  // Enregistrement du fichier
26  const bytes = await file.arrayBuffer();
27  const buffer = Buffer.from(bytes);
28  const filename = Date.now() + '-' + file.name;
29  const filepath = path.join(process.cwd(), 'public/uploads', filename);
30
31  await writeFile(filepath, buffer);
32
33  return { success: true, url: '/uploads/' + filename };
34}

Le paramètre prevState est nécessaire parce que useActionState passe toujours l'état précédent comme premier argument. La limite par défaut du corps de requête d'une Server Action est de 1 Mo, donc pour des fichiers jusqu'à 5 Mo augmente experimental.serverActions.bodySizeLimit dans next.config.js.

Le composant client est court, car c'est le hook qui gère l'état :

1// components/FileUpload.tsx
2'use client';
3
4import { useActionState } from 'react';
5import { uploadFile } from '@/actions/upload';
6
7export default function FileUpload() {
8  const [state, formAction, isPending] = useActionState(uploadFile, null);
9
10  return (
11    <form action={formAction}>
12      <input type="file" name="file" accept="image/*" />
13      <button type="submit" disabled={isPending}>
14        {isPending ? 'Envoi...' : 'Envoyer le fichier'}
15      </button>
16
17      {state?.error && <p className="text-red-500">{state.error}</p>}
18      {state?.success && (
19        <img src={state.url} alt="Fichier envoyé" className="mt-4 max-w-xs" />
20      )}
21    </form>
22  );
23}

Après un envoi réussi tu vois un aperçu de l'image, et en cas d'échec le message de l'action. Aucun fetch manuel, Next.js s'occupe de la communication.

Résumé

Les Server Actions sont un mécanisme puissant de Next.js qui simplifie la communication client-serveur :

  1. useOptimistic - mise à jour instantanée de l'UI avant la réponse du serveur
  2. revalidatePath / revalidateTag - revalidation précise des données après une mutation
  3. Gestion des erreurs - réponses structurées avec validation Zod
  4. Progressive enhancement - les formulaires fonctionnent sans JavaScript
  5. Composition - regrouper plusieurs opérations dans un seul flux
  6. Upload de fichiers - prise en charge native de l'envoi de fichiers

Mon conseil : renvoie depuis tes actions un objet avec un champ success au lieu de lever des exceptions, et utilise useOptimistic là où l'opération réussit presque toujours. Dans la leçon suivante, tu construiras des tableaux avec TanStack Table, et tu reviendras aux tags avec use cache.

Retiens : dans la Métropole Quantum, une bonne Server Action valide, enregistre, revalide et dit toujours à l'interface comment ça s'est passé.

Code de cette leçon : App.tsx
1import React, { useState, useCallback } from 'react';
2
3interface Task {
4  id: number;
5  title: string;
6  completed: boolean;
7  pending?: boolean;
8}
9
10interface ActionResult {
11  success: boolean;
12  error?: string;
13}
14
15// Simule une server action asynchrone
16const simulateServerAction = (ms: number): Promise<ActionResult> =>
17  new Promise(resolve => setTimeout(() => resolve({ success: true }), ms));
18
19const ServerActionsDemo = () => {
20  const [tasks, setTasks] = useState<Task[]>([
21    { id: 1, title: 'Configurer revalidatePath', completed: true },
22    { id: 2, title: 'Ajouter useOptimistic', completed: false },
23    { id: 3, title: 'Gérer les erreurs dans une Server Action', completed: false },
24    { id: 4, title: "Implémenter l'upload de fichier", completed: false },
25  ]);
26  const [newTask, setNewTask] = useState('');
27  const [actionLog, setActionLog] = useState<string[]>([]);
28  const [uploading, setUploading] = useState(false);
29  const [uploadResult, setUploadResult] = useState<string | null>(null);
30  const [formError, setFormError] = useState<string | null>(null);
31
32  const addLog = (msg: string) => setActionLog(prev => [...prev.slice(-8), msg]);
33
34  const addTask = async () => {
35    if (!newTask.trim()) return;
36    const tempId = Date.now();
37    // Mise à jour optimiste
38    setTasks(prev => [...prev, { id: tempId, title: newTask, completed: false, pending: true }]);
39    addLog('useOptimistic : UI mise à jour instantanément');
40    setNewTask('');
41
42    // Simule la server action
43    await simulateServerAction(800);
44    setTasks(prev => prev.map(t => t.id === tempId ? { ...t, pending: false } : t));
45    addLog('Server Action : tâche enregistrée, revalidatePath appelé');
46  };
47
48  const toggleTask = async (id: number) => {
49    // Bascule optimiste
50    setTasks(prev => prev.map(t => t.id === id ? { ...t, completed: !t.completed, pending: true } : t));
51    addLog('useOptimistic : bascule appliquée instantanément');
52
53    await simulateServerAction(500);
54    setTasks(prev => prev.map(t => t.id === id ? { ...t, pending: false } : t));
55    addLog('Serveur : revalidateTag("tasks") appelé');
56  };
57
58  const deleteTask = async (id: number) => {
59    setTasks(prev => prev.filter(t => t.id !== id));
60    addLog("Optimiste : tâche retirée de l'UI");
61    await simulateServerAction(400);
62    addLog('Serveur : tâche supprimée, cache revalidé');
63  };
64
65  const simulateUpload = async () => {
66    setUploading(true);
67    setUploadResult(null);
68    addLog("Server Action : traitement de l'upload du fichier...");
69    await simulateServerAction(1200);
70    setUploadResult('/uploads/quantum-report-2150.pdf');
71    setUploading(false);
72    addLog('Serveur : fichier enregistré dans /uploads/');
73  };
74
75  const simulateFormError = async () => {
76    setFormError(null);
77    addLog('Server Action : validation du formulaire avec Zod...');
78    await simulateServerAction(600);
79    setFormError("L'email est requis. Le mot de passe doit contenir au moins 8 caractères.");
80    addLog('Serveur : validation échouée, renvoi de fieldErrors');
81  };
82
83  return (
84    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: '20px', color: '#fff', fontFamily: 'sans-serif' }}>
85      <h1 style={{ color: '#64ffda', textAlign: 'center' }}>Server Actions - Patterns avancés</h1>
86      <p style={{ textAlign: 'center', color: '#888', marginBottom: '20px' }}>useOptimistic + revalidation + gestion des erreurs + upload de fichier</p>
87
88      <div style={{ maxWidth: '800px', margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
89        <div>
90          <h3 style={{ color: '#7c4dff', marginBottom: '10px' }}>Mises à jour optimistes</h3>
91          <div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
92            <input value={newTask} onChange={e => setNewTask(e.target.value)} placeholder="Nouvelle tâche..."
93              onKeyDown={e => e.key === 'Enter' && addTask()}
94              style={{ flex: 1, padding: '8px', background: '#16213e', border: '1px solid #444', borderRadius: '6px', color: '#fff' }} />
95            <button onClick={addTask} style={{ padding: '8px 16px', background: '#7c4dff', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer' }}>Ajouter</button>
96          </div>
97
98          <div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '8px', overflow: 'hidden' }}>
99            {tasks.map(task => (
100              <div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 12px', borderBottom: '1px solid #1a1a2e', opacity: task.pending ? 0.6 : 1 }}>
101                <input type="checkbox" checked={task.completed} onChange={() => toggleTask(task.id)} />
102                <span style={{ flex: 1, textDecoration: task.completed ? 'line-through' : 'none', color: task.completed ? '#555' : '#fff' }}>
103                  {task.title}
104                  {task.pending && <span style={{ color: '#ff9800', fontSize: '11px', marginLeft: '6px' }}>(synchronisation...)</span>}
105                </span>
106                <button onClick={() => deleteTask(task.id)} style={{ background: 'none', border: 'none', color: '#f44336', cursor: 'pointer' }}>x</button>
107              </div>
108            ))}
109          </div>
110
111          <h3 style={{ color: '#7c4dff', margin: '16px 0 10px' }}>Gestion des erreurs</h3>
112          <button onClick={simulateFormError} style={{ padding: '8px 16px', background: '#f44336', border: 'none', borderRadius: '6px', color: '#fff', cursor: 'pointer', marginBottom: '8px' }}>
113            Simuler une erreur de validation du formulaire
114          </button>
115          {formError && (
116            <div style={{ background: 'rgba(244,67,54,0.15)', border: '1px solid #f44336', padding: '10px', borderRadius: '6px', color: '#f44336', fontSize: '13px' }}>
117              {formError}
118            </div>
119          )}
120
121          <h3 style={{ color: '#7c4dff', margin: '16px 0 10px' }}>Upload de fichier</h3>
122          <button onClick={simulateUpload} disabled={uploading} style={{ padding: '8px 16px', background: uploading ? '#555' : '#4caf50', border: 'none', borderRadius: '6px', color: '#fff', cursor: uploading ? 'default' : 'pointer' }}>
123            {uploading ? 'Upload en cours...' : 'Uploader un fichier'}
124          </button>
125          {uploadResult && <p style={{ color: '#4caf50', fontSize: '13px', marginTop: '6px' }}>Enregistré : {uploadResult}</p>}
126        </div>
127
128        <div>
129          <h3 style={{ color: '#7c4dff', marginBottom: '10px' }}>Journal des actions</h3>
130          <div style={{ background: '#000', padding: '12px', borderRadius: '8px', minHeight: '300px', maxHeight: '500px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '11px' }}>
131            {actionLog.length === 0 ? (
132              <span style={{ color: '#555' }}>Interagis avec la démo pour voir les logs des Server Actions...</span>
133            ) : actionLog.map((log, i) => (
134              <div key={i} style={{ color: log.includes('Server') ? '#4caf50' : '#64ffda', padding: '3px 0' }}>
135                {log.includes('Server') ? '>> ' : '-> '}{log}
136              </div>
137            ))}
138          </div>
139        </div>
140      </div>
141    </div>
142  );
143};
144
145export default ServerActionsDemo;

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel hook React permet de mettre à jour l'UI immédiatement, avant la réponse du serveur, dans les Server Actions ?

  2. 2. Quelle fonction de Next.js permet de rafraîchir les données à partir d'un cache tag après l'exécution d'une Server Action ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Écris une Server Action qui valide les données d'un formulaire et renvoie un résultat structuré avec un champ success et une erreur optionnelle.

  • Clique dans l'ordre

    Remets dans l'ordre l'implémentation des opérations côté serveur.

Articles utiles