Cours Next.js · Module 6: Formulaires et Server Actions

Gestion des erreurs dans les Server Actions

7 min de lecture
Dans cette leçon8

Dans la Métropole Quantique, même les meilleurs systèmes peuvent rencontrer des erreurs - saisies utilisateur inattendues, problèmes de base de données ou pannes d'API externes. Une bonne gestion des erreurs dans les Server Actions est un élément clé de la construction d'applications Next.js fiables. Dans cette leçon, tu apprendras des modèles qui te permettront de gérer élégamment les erreurs et d'en informer les utilisateurs.

Try/catch dans les Server Actions

Le modèle de base de gestion des erreurs dans les Server Actions est le try/catch. Chaque server action doit anticiper la possibilité qu'une erreur survienne :

1// app/actions.ts
2'use server';
3
4export async function createImplant(formData: FormData) {
5  try {
6    const name = formData.get('name') as string;
7    const powerLevel = Number(formData.get('powerLevel'));
8
9    // Opération susceptible d'échouer
10    const result = await db.implants.create({ name, powerLevel });
11
12    return { success: true, data: result };
13  } catch (error) {
14    // Ne JAMAIS renvoyer l'objet d'erreur brut au client !
15    console.error("Erreur de création d'implant :", error);
16    return {
17      success: false,
18      error: "Échec de la création de l'implant. Réessaie."
19    };
20  }
21}

Règle clé : Les Server Actions ne doivent pas propager d'exceptions vers le client. À la place, renvoie un objet structuré avec l'information de succès ou d'erreur.

Modèle pour renvoyer des objets d'erreur

Dans les applications professionnelles, il est utile de définir un type cohérent pour les réponses des Server Actions :

1// lib/types.ts
2type ActionState = {
3  status: 'idle' | 'success' | 'error';
4  message: string;
5  errors?: Record<string, string[]>; // Erreurs de formulaire par champ
6};
7
8// app/actions.ts
9'use server';
10
11export async function updateProfile(
12  prevState: ActionState,
13  formData: FormData
14): Promise<ActionState> {
15  const name = formData.get('name') as string;
16  const email = formData.get('email') as string;
17
18  // Validation
19  const errors: Record<string, string[]> = {};
20
21  if (!name || name.length < 2) {
22    errors.name = ['Le prénom doit contenir au moins 2 caractères'];
23  }
24  if (!email || !email.includes('@')) {
25    errors.email = ["Format d'adresse email invalide"];
26  }
27
28  if (Object.keys(errors).length > 0) {
29    return {
30      status: 'error',
31      message: 'Le formulaire contient des erreurs',
32      errors,
33    };
34  }
35
36  try {
37    await db.users.update({ name, email });
38    return { status: 'success', message: 'Profil mis à jour !' };
39  } catch (error) {
40    return { status: 'error', message: 'Erreur serveur. Réessaie.' };
41  }
42}

Ce modèle - un objet avec status, message et un champ facultatif errors - permet au client de comprendre précisément ce qui a mal tourné.

useActionState pour afficher les erreurs

Le hook useActionState (anciennement useFormState) est la façon officielle de connecter les Server Actions à l'interface utilisateur en React/Next.js :

1'use client';
2
3import { useActionState } from 'react';
4import { updateProfile } from './actions';
5
6const initialState = {
7  status: 'idle',
8  message: '',
9  errors: {},
10};
11
12export default function ProfileForm() {
13  const [state, formAction, isPending] = useActionState(
14    updateProfile,
15    initialState
16  );
17
18  return (
19    <form action={formAction}>
20      <div>
21        <label htmlFor="name">Prénom</label>
22        <input id="name" name="name" />
23        {state.errors?.name && (
24          <p className="text-red-500">{state.errors.name[0]}</p>
25        )}
26      </div>
27
28      <div>
29        <label htmlFor="email">Email</label>
30        <input id="email" name="email" type="email" />
31        {state.errors?.email && (
32          <p className="text-red-500">{state.errors.email[0]}</p>
33        )}
34      </div>
35
36      <button type="submit" disabled={isPending}>
37        {isPending ? 'Enregistrement...' : 'Enregistrer le profil'}
38      </button>
39
40      {state.status === 'success' && (
41        <p className="text-green-500">{state.message}</p>
42      )}
43      {state.status === 'error' && !state.errors && (
44        <p className="text-red-500">{state.message}</p>
45      )}
46    </form>
47  );
48}

useActionState prend trois arguments : la server action, l'état initial, et renvoie un tableau : l'état actuel, la fonction formAction (à passer à l'action du formulaire) et le drapeau isPending.

Validation Zod dans les Server Actions

Combiner Zod avec les Server Actions est un modèle puissant - le schéma de validation est partagé entre le client et le serveur :

1// lib/schemas.ts
2import { z } from 'zod';
3
4export const implantSchema = z.object({
5  name: z.string()
6    .min(2, "Le nom de l'implant doit contenir au moins 2 caractères")
7    .max(50, "Le nom de l'implant peut contenir jusqu'à 50 caractères"),
8  powerLevel: z.coerce.number()
9    .min(1, 'Le niveau de puissance doit être supérieur à 0')
10    .max(100, 'Le niveau de puissance ne peut pas dépasser 100'),
11  type: z.enum(['neural', 'cybernetic', 'bionic'], {
12    errorMap: () => ({ message: "Sélectionne un type d'implant" }),
13  }),
14});
15
16// app/actions.ts
17'use server';
18
19import { implantSchema } from '@/lib/schemas';
20
21export async function createImplant(prevState: any, formData: FormData) {
22  // Validation avec Zod
23  const parsed = implantSchema.safeParse({
24    name: formData.get('name'),
25    powerLevel: formData.get('powerLevel'),
26    type: formData.get('type'),
27  });
28
29  if (!parsed.success) {
30    // Convertir les erreurs Zod au format champ -> messages
31    const fieldErrors: Record<string, string[]> = {};
32    for (const issue of parsed.error.issues) {
33      const field = issue.path[0] as string;
34      if (!fieldErrors[field]) fieldErrors[field] = [];
35      fieldErrors[field].push(issue.message);
36    }
37    return { status: 'error', message: 'La validation a échoué', errors: fieldErrors };
38  }
39
40  try {
41    // parsed.data est typé et validé !
42    await db.implants.create(parsed.data);
43    return { status: 'success', message: 'Implant créé !' };
44  } catch (error) {
45    return { status: 'error', message: "Erreur d'écriture en base de données" };
46  }
47}

Remarque z.coerce.number() - convertit une chaîne de FormData en nombre avant la validation. C'est essentiel car formData.get() renvoie toujours une chaîne.

Types d'erreurs personnalisés

Dans les applications complexes, il est utile de définir des types d'erreurs personnalisés pour distinguer leur source :

1// lib/errors.ts
2export class ValidationError extends Error {
3  constructor(
4    public fieldErrors: Record<string, string[]>,
5    message = 'La validation a échoué'
6  ) {
7    super(message);
8    this.name = 'ValidationError';
9  }
10}
11
12export class DatabaseError extends Error {
13  constructor(message = 'Erreur de base de données') {
14    super(message);
15    this.name = 'DatabaseError';
16  }
17}
18
19export class AuthorizationError extends Error {
20  constructor(message = 'Permissions insuffisantes') {
21    super(message);
22    this.name = 'AuthorizationError';
23  }
24}
25
26// app/actions.ts
27'use server';
28
29import { ValidationError, DatabaseError, AuthorizationError } from '@/lib/errors';
30
31export async function deleteImplant(prevState: any, formData: FormData) {
32  try {
33    const id = formData.get('id') as string;
34
35    if (!id) {
36      throw new ValidationError({ id: ["L'ID de l'implant est requis"] });
37    }
38
39    const user = await getCurrentUser();
40    if (!user?.isAdmin) {
41      throw new AuthorizationError('Seuls les administrateurs peuvent supprimer des implants');
42    }
43
44    await db.implants.delete(id);
45    return { status: 'success', message: 'Implant supprimé' };
46
47  } catch (error) {
48    if (error instanceof ValidationError) {
49      return { status: 'error', message: error.message, errors: error.fieldErrors };
50    }
51    if (error instanceof AuthorizationError) {
52      return { status: 'error', message: error.message };
53    }
54    if (error instanceof DatabaseError) {
55      return { status: 'error', message: 'Erreur système. Réessaie plus tard.' };
56    }
57    // Erreur inconnue - ne pas exposer les détails à l'utilisateur
58    console.error('Erreur inattendue :', error);
59    return { status: 'error', message: "Une erreur inattendue s'est produite" };
60  }
61}

Messages conviviaux

Les messages d'erreur doivent être compréhensibles et utiles - l'utilisateur doit savoir quoi faire :

1// Mauvais messages :
2"Error: ECONNREFUSED 127.0.0.1:5432"  // Technique, incompréhensible
3"La validation a échoué"                // Trop générique
4"null"                                  // Absurde
5
6// Bons messages :
7"Échec de l'enregistrement des données. Vérifie ta connexion et réessaie."
8"Le nom de l'implant doit contenir au moins 2 caractères."
9"Session expirée. Reconnecte-toi pour continuer."

Comportements de repli

Lorsqu'une Server Action échoue, l'application doit avoir un plan de secours :

1'use client';
2
3import { useActionState } from 'react';
4import { saveData } from './actions';
5
6export default function FormWithFallback() {
7  const [state, formAction, isPending] = useActionState(saveData, {
8    status: 'idle',
9    message: '',
10  });
11
12  // Repli : réessayer après une erreur
13  const handleRetry = () => {
14    // Réinitialiser l'état et laisser l'utilisateur réessayer
15    window.location.reload();
16  };
17
18  if (state.status === 'error' && state.message.includes('server')) {
19    return (
20      <div>
21        <p>Un problème serveur est survenu.</p>
22        <button onClick={handleRetry}>Réessayer</button>
23        <p>Si le problème persiste, contacte l'administrateur.</p>
24      </div>
25    );
26  }
27
28  return (
29    <form action={formAction}>
30      {/* ... champs du formulaire ... */}
31      <button type="submit" disabled={isPending}>
32        {isPending ? 'Enregistrement...' : 'Enregistrer'}
33      </button>
34    </form>
35  );
36}

Résumé

La gestion des erreurs dans les Server Actions est un élément critique des applications Next.js fiables :

  • try/catch dans chaque Server Action - ne laisse jamais des erreurs brutes atteindre le client
  • Objets d'erreur structurés - { status, message, errors } au lieu de propager des exceptions
  • useActionState - hook officiel pour connecter les actions à l'interface et afficher les erreurs par champ
  • Zod dans les Server Actions - safeParse() + z.coerce pour valider les données de FormData
  • Types d'erreurs personnalisés - distinguer les sources d'erreur (validation, autorisation, base de données)
  • Messages conviviaux - compréhensibles, précis, avec des instructions sur la suite à donner
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Simulation de la gestion des erreurs dans les Server Actions (Next.js)
4// try/catch, objets d'erreur, useActionState, validation Zod
5
6type ActionState = {
7  status: 'idle' | 'success' | 'error';
8  message: string;
9  errors?: Record<string, string[]>;
10};
11
12// Validation simplifiee (simulation de Zod safeParse)
13function validateImplant(data: Record<string, string>) {
14  const errors: Record<string, string[]> = {};
15  if (!data.name || data.name.length < 2) {
16    errors.name = ['Le nom doit comporter au moins 2 caracteres'];
17  }
18  if (!data.powerLevel || isNaN(Number(data.powerLevel))) {
19    errors.powerLevel = ['Nombre requis'];
20  } else {
21    const pl = Number(data.powerLevel);
22    if (pl < 1 || pl > 100) errors.powerLevel = ['Niveau de puissance : 1-100'];
23  }
24  if (!data.type) {
25    errors.type = ['Choisis un type d\'implant'];
26  }
27  return { success: Object.keys(errors).length === 0, errors };
28}
29
30// Simulation d'une Server Action avec try/catch
31async function createImplantAction(data: Record<string, string>): Promise<ActionState> {
32  await new Promise(r => setTimeout(r, 800));
33
34  // 1. Validation avec Zod (safeParse)
35  const validation = validateImplant(data);
36  if (!validation.success) {
37    return { status: 'error', message: 'La validation a echoue', errors: validation.errors };
38  }
39
40  // 2. Simulation d'une erreur serveur
41  try {
42    if (data.name.toLowerCase() === 'error') {
43      throw new Error('Database connection failed');
44    }
45    if (data.name.toLowerCase() === 'auth') {
46      throw { type: 'AuthorizationError', message: 'Aucune autorisation pour creer des implants' };
47    }
48    return { status: 'success', message: `Implant "${data.name}" (puissance : ${data.powerLevel}) cree !` };
49  } catch (error: any) {
50    // NE JAMAIS renvoyer l'erreur brute au client
51    if (error.type === 'AuthorizationError') {
52      return { status: 'error', message: error.message };
53    }
54    console.error('Server error:', error);
55    return { status: 'error', message: 'Erreur serveur. Reessaie plus tard.' };
56  }
57}
58
59export default function ServerActionsErrorDemo() {
60  const [values, setValues] = useState({ name: '', powerLevel: '', type: '' });
61  const [state, setState] = useState<ActionState>({ status: 'idle', message: '' });
62  const [isPending, setIsPending] = useState(false);
63
64  const handleSubmit = async (e: React.FormEvent) => {
65    e.preventDefault();
66    setIsPending(true);
67    setState({ status: 'idle', message: '' });
68    const result = await createImplantAction(values);
69    setState(result);
70    setIsPending(false);
71  };
72
73  const inputStyle: React.CSSProperties = {
74    width: '100%', padding: '10px', background: '#0d1117',
75    border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
76    fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
77  };
78
79  const types = ['neural', 'cybernetic', 'bionic'];
80
81  const codeExample = `// app/actions.ts
82"use server";
83
84export async function createImplant(prevState, formData) {
85  const parsed = implantSchema.safeParse({...});
86  if (!parsed.success) {
87    return { status: 'error', errors: parsed.error.issues };
88  }
89  try {
90    await db.implants.create(parsed.data);
91    return { status: 'success', message: 'Cree !' };
92  } catch (error) {
93    return { status: 'error', message: 'Erreur serveur' };
94  }
95}`;
96
97  return (
98    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
99      <h1 style={{ color: '#64ffda' }}>Gestion des erreurs dans les Server Actions</h1>
100      <p style={{ color: '#888', fontSize: 13, marginBottom: 12 }}>
101        Saisis "error" dans le nom = erreur serveur. "auth" = pas d'autorisation. Champs vides = validation Zod.
102      </p>
103
104      <pre style={{ background: '#0d1117', border: '1px solid #333', borderRadius: 8, padding: 12, fontSize: 11, color: '#ce93d8', marginBottom: 16, whiteSpace: 'pre-wrap' }}>
105{codeExample}
106      </pre>
107
108      <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
109        <div style={{ marginBottom: 12 }}>
110          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Nom de l'implant</label>
111          <input
112            value={values.name}
113            onChange={e => setValues(p => ({ ...p, name: e.target.value }))}
114            placeholder="Neural Link"
115            style={{ ...inputStyle, borderColor: state.errors?.name ? '#f44336' : '#333' }}
116          />
117          {state.errors?.name && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.name[0]}</div>}
118        </div>
119
120        <div style={{ marginBottom: 12 }}>
121          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Niveau de puissance (1-100)</label>
122          <input
123            value={values.powerLevel}
124            onChange={e => setValues(p => ({ ...p, powerLevel: e.target.value }))}
125            placeholder="75"
126            style={{ ...inputStyle, borderColor: state.errors?.powerLevel ? '#f44336' : '#333' }}
127          />
128          {state.errors?.powerLevel && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.powerLevel[0]}</div>}
129        </div>
130
131        <div style={{ marginBottom: 12 }}>
132          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Type d'implant</label>
133          <div style={{ display: 'flex', gap: 8 }}>
134            {types.map(t => (
135              <button key={t} type="button" onClick={() => setValues(p => ({ ...p, type: t }))} style={{
136                flex: 1, padding: '8px', borderRadius: 6, fontFamily: 'monospace', fontSize: 12, cursor: 'pointer',
137                background: values.type === t ? '#1565c0' : '#0d1117',
138                color: values.type === t ? '#fff' : '#aaa',
139                border: `1px solid ${values.type === t ? '#64ffda' : state.errors?.type ? '#f44336' : '#333'}`
140              }}>
141                {t}
142              </button>
143            ))}
144          </div>
145          {state.errors?.type && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.type[0]}</div>}
146        </div>
147
148        <button type="submit" disabled={isPending} style={{
149          background: isPending ? '#555' : '#e91e63', color: '#fff', border: 'none', borderRadius: 6,
150          padding: '10px 20px', cursor: isPending ? 'wait' : 'pointer', fontFamily: 'monospace', width: '100%'
151        }}>
152          {isPending ? 'Validation sur le serveur...' : 'createImplant() - Server Action'}
153        </button>
154      </form>
155
156      {state.status !== 'idle' && !state.errors && (
157        <div style={{
158          marginTop: 16, borderRadius: 8, padding: 12, maxWidth: 400,
159          background: state.status === 'success' ? '#1b5e20' : '#3e1010',
160          border: `1px solid ${state.status === 'success' ? '#4caf50' : '#f44336'}`
161        }}>
162          <div style={{ color: state.status === 'success' ? '#4caf50' : '#f44336' }}>
163            {state.message}
164          </div>
165        </div>
166      )}
167    </div>
168  );
169}

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

Articles utiles