React 19 Server Actions et useActionState : Le guide complet de la gestion des formulaires

Des formulaires sans boilerplate. React 19 change fondamentalement la gestion des formulaires — les Server Actions et useActionState vous permettent de construire des formulaires type-safe qui fonctionnent même avant le chargement du JavaScript.
Ce que vous allez apprendre
- Comment les Server Actions remplacent les routes API pour les mutations de formulaires
- Utiliser
useActionStatepour la gestion de l'état des formulaires avec les états de chargement - Construire des formulaires progressivement améliorés qui fonctionnent sans JavaScript
- La validation type-safe côté serveur avec Zod
- Les mises à jour optimistes avec
useOptimistic - Des patterns concrets : formulaires multi-étapes, upload de fichiers et gestion des erreurs
Prérequis
Avant de commencer ce tutoriel, assurez-vous d'avoir :
- Node.js 20+ installé
- Des connaissances de base en React et TypeScript
- Une familiarité avec le Next.js App Router (pages, layouts, composants serveur)
- Un éditeur de code comme VS Code
Pourquoi les Server Actions changent tout
Avant React 19, la gestion des formulaires en React nécessitait généralement :
- Créer une route API séparée (
/api/submit-form) - Utiliser
useStatepour chaque champ du formulaire - Écrire des handlers
onChangepour chaque input - Gérer manuellement les états de chargement, erreur et succès
- Appeler
fetch()ou une bibliothèque pour soumettre les données
C'est beaucoup de plomberie pour quelque chose d'aussi fondamental qu'un formulaire. Les Server Actions éliminent la majeure partie de cette complexité en vous permettant de définir des fonctions serveur que React appelle directement depuis le client — sans routes API, sans appels fetch manuels, sans gestion d'état séparée.
// Avant : L'ancienne méthode
'use client'
import { useState } from 'react'
export default function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ name, email }),
})
if (!res.ok) throw new Error('Failed')
} catch (err) {
setError('Something went wrong')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* ... beaucoup d'inputs contrôlés */}
</form>
)
}// Après : Avec les Server Actions
import { submitContact } from './actions'
export default function ContactForm() {
return (
<form action={submitContact}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Envoyer</button>
</form>
)
}La version "après" fonctionne sans JavaScript, n'a pas d'état côté client, et la fonction serveur gère tout.
Étape 1 : Configuration du projet
Créons un nouveau projet Next.js 15 avec React 19 :
npx create-next-app@latest react19-forms --typescript --tailwind --app --src-dir
cd react19-formsVérifiez que React 19 est dans votre package.json :
{
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "^15.0.0"
}
}Installez Zod pour la validation côté serveur :
npm install zodÉtape 2 : Votre première Server Action
Créez un fichier pour vos actions serveur. La directive "use server" en haut du fichier indique à React que ce module s'exécute uniquement sur le serveur :
// src/app/actions.ts
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
// Ceci s'exécute sur le serveur — accès sécurisé aux bases de données, secrets, etc.
console.log('Creating user:', { name, email })
// Simulation d'insertion en base de données
await new Promise((resolve) => setTimeout(resolve, 1000))
return { success: true, message: `User ${name} created!` }
}Maintenant utilisez-la dans un formulaire. C'est un Composant Serveur — pas besoin de "use client" :
// src/app/page.tsx
import { createUser } from './actions'
export default function HomePage() {
return (
<main className="max-w-md mx-auto mt-20 p-6">
<h1 className="text-2xl font-bold mb-6">Créer un compte</h1>
<form action={createUser} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Nom
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full border rounded-lg px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded-lg px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700"
>
Créer
</button>
</form>
</main>
)
}Ce formulaire fonctionne même avec JavaScript désactivé ! Le navigateur soumet le formulaire nativement, et Next.js gère la Server Action côté serveur.
Étape 3 : Ajouter useActionState pour un retour enrichi
Le formulaire basique fonctionne, mais il n'y a pas de retour — pas d'état de chargement, pas de messages d'erreur, pas de confirmation de succès. C'est là qu'intervient useActionState.
useActionState est un hook React 19 qui gère le cycle de vie d'une action de formulaire :
const [state, formAction, isPending] = useActionState(action, initialState)state— La valeur de retour actuelle de l'action (erreurs, messages de succès, etc.)formAction— Une version encapsulée de votre action à passer à<form action={...}>isPending— Booléen indiquant si l'action est en cours d'exécution
D'abord, mettez à jour votre Server Action pour retourner un état structuré :
// src/app/actions.ts
'use server'
export type FormState = {
success: boolean
message: string
errors?: {
name?: string[]
email?: string[]
}
}
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const name = formData.get('name') as string
const email = formData.get('email') as string
// Validation basique
const errors: FormState['errors'] = {}
if (!name || name.length < 2) {
errors.name = ['Le nom doit contenir au moins 2 caractères']
}
if (!email || !email.includes('@')) {
errors.email = ['Veuillez entrer un email valide']
}
if (Object.keys(errors).length > 0) {
return { success: false, message: 'La validation a échoué', errors }
}
// Simulation d'opération en base de données
await new Promise((resolve) => setTimeout(resolve, 1000))
return { success: true, message: `Bienvenue, ${name} !` }
}Notez que la signature de la fonction a changé — useActionState passe l'état précédent comme premier argument et formData comme second. C'est différent d'une Server Action simple où formData est le seul argument.
Maintenant créez le composant client avec useActionState :
// src/app/create-user-form.tsx
'use client'
import { useActionState } from 'react'
import { createUser, type FormState } from './actions'
const initialState: FormState = {
success: false,
message: '',
}
export default function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, initialState)
return (
<form action={formAction} className="space-y-4">
{/* Message de succès */}
{state.success && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
{state.message}
</div>
)}
{/* Message d'erreur général */}
{!state.success && state.message && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
{state.message}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Nom
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full border rounded-lg px-3 py-2"
aria-describedby="name-error"
/>
{state.errors?.name && (
<p id="name-error" className="text-red-600 text-sm mt-1">
{state.errors.name[0]}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded-lg px-3 py-2"
aria-describedby="email-error"
/>
{state.errors?.email && (
<p id="email-error" className="text-red-600 text-sm mt-1">
{state.errors.email[0]}
</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? 'Création en cours...' : 'Créer le compte'}
</button>
</form>
)
}Mettez à jour votre page pour utiliser le nouveau composant :
// src/app/page.tsx
import CreateUserForm from './create-user-form'
export default function HomePage() {
return (
<main className="max-w-md mx-auto mt-20 p-6">
<h1 className="text-2xl font-bold mb-6">Créer un compte</h1>
<CreateUserForm />
</main>
)
}Étape 4 : Validation type-safe avec Zod
La validation codée en dur est fragile. Utilisons Zod pour une validation robuste et type-safe :
// src/lib/schemas.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z
.string()
.min(2, 'Le nom doit contenir au moins 2 caractères')
.max(50, 'Le nom doit contenir moins de 50 caractères'),
email: z
.string()
.email('Veuillez entrer une adresse email valide'),
password: z
.string()
.min(8, 'Le mot de passe doit contenir au moins 8 caractères')
.regex(/[A-Z]/, 'Le mot de passe doit contenir au moins une majuscule')
.regex(/[0-9]/, 'Le mot de passe doit contenir au moins un chiffre'),
})
export type CreateUserInput = z.infer<typeof createUserSchema>Mettez à jour la Server Action pour utiliser Zod :
// src/app/actions.ts
'use server'
import { createUserSchema } from '@/lib/schemas'
export type FormState = {
success: boolean
message: string
errors?: Record<string, string[]>
}
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Parsing et validation avec Zod
const result = createUserSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
})
if (!result.success) {
return {
success: false,
message: 'Veuillez corriger les erreurs ci-dessous',
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
}
}
// result.data est maintenant entièrement typé comme CreateUserInput
const { name, email, password } = result.data
try {
// Simulation d'insertion en base de données
await new Promise((resolve) => setTimeout(resolve, 1000))
// En production : hasher le mot de passe, insérer en base
console.log('Creating user:', { name, email })
return { success: true, message: `Compte créé pour ${name} !` }
} catch (error) {
return { success: false, message: 'Échec de la création du compte. Veuillez réessayer.' }
}
}Validez toujours côté serveur, même si vous validez aussi côté client. La validation côté client peut être contournée — la validation côté serveur est votre frontière de sécurité.
Étape 5 : Mises à jour optimistes avec useOptimistic
Pour les actions où vous souhaitez un retour instantané (comme ajouter un favori ou poster un commentaire), React 19 fournit useOptimistic :
// src/app/comments/comment-form.tsx
'use client'
import { useActionState, useOptimistic } from 'react'
import { addComment, type CommentState } from './actions'
type Comment = {
id: string
text: string
author: string
pending?: boolean
}
export default function CommentSection({
initialComments,
}: {
initialComments: Comment[]
}) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state: Comment[], newComment: Comment) => [
...state,
{ ...newComment, pending: true },
]
)
const initialState: CommentState = { success: false, message: '' }
const [state, formAction, isPending] = useActionState(
async (prevState: CommentState, formData: FormData) => {
// Ajouter le commentaire optimiste immédiatement
addOptimisticComment({
id: crypto.randomUUID(),
text: formData.get('text') as string,
author: 'Vous',
pending: true,
})
// Puis exécuter la server action réelle
return addComment(prevState, formData)
},
initialState
)
return (
<div className="space-y-4">
{/* Liste des commentaires */}
<ul className="space-y-3">
{optimisticComments.map((comment) => (
<li
key={comment.id}
className={`p-3 rounded-lg border ${
comment.pending ? 'opacity-50 bg-gray-50' : 'bg-white'
}`}
>
<p className="font-medium">{comment.author}</p>
<p className="text-gray-600">{comment.text}</p>
{comment.pending && (
<span className="text-xs text-gray-400">Envoi en cours...</span>
)}
</li>
))}
</ul>
{/* Formulaire de commentaire */}
<form action={formAction} className="flex gap-2">
<input
name="text"
placeholder="Ajouter un commentaire..."
required
className="flex-1 border rounded-lg px-3 py-2"
/>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
Publier
</button>
</form>
</div>
)
}Le commentaire apparaît instantanément dans l'interface avec un état visuel "en attente", puis est confirmé (ou annulé) lorsque le serveur répond.
Étape 6 : Pattern de formulaire multi-étapes
Pour les formulaires complexes comme les flux d'onboarding, vous pouvez combiner les Server Actions avec l'état client pour construire des assistants multi-étapes :
// src/app/onboarding/onboarding-form.tsx
'use client'
import { useState } from 'react'
import { useActionState } from 'react'
import { completeOnboarding, type OnboardingState } from './actions'
const steps = ['Profil', 'Préférences', 'Vérification'] as const
export default function OnboardingForm() {
const [currentStep, setCurrentStep] = useState(0)
const initialState: OnboardingState = {
success: false,
message: '',
step: 0,
}
const [state, formAction, isPending] = useActionState(
completeOnboarding,
initialState
)
return (
<div className="max-w-lg mx-auto">
{/* Barre de progression */}
<div className="flex mb-8">
{steps.map((step, index) => (
<div
key={step}
className={`flex-1 text-center py-2 text-sm font-medium ${
index <= currentStep
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-400 border-b-2 border-gray-200'
}`}
>
{step}
</div>
))}
</div>
<form action={formAction}>
{/* Champ caché pour suivre l'étape */}
<input type="hidden" name="step" value={currentStep} />
{/* Étape 1 : Profil */}
{currentStep === 0 && (
<div className="space-y-4">
<input name="fullName" placeholder="Nom complet" required
className="w-full border rounded-lg px-3 py-2" />
<input name="company" placeholder="Entreprise" required
className="w-full border rounded-lg px-3 py-2" />
</div>
)}
{/* Étape 2 : Préférences */}
{currentStep === 1 && (
<div className="space-y-4">
<select name="role" className="w-full border rounded-lg px-3 py-2">
<option value="developer">Développeur</option>
<option value="designer">Designer</option>
<option value="manager">Chef de produit</option>
</select>
<select name="experience" className="w-full border rounded-lg px-3 py-2">
<option value="junior">Junior (0-2 ans)</option>
<option value="mid">Intermédiaire (2-5 ans)</option>
<option value="senior">Senior (5+ ans)</option>
</select>
</div>
)}
{/* Étape 3 : Vérification */}
{currentStep === 2 && (
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-gray-600">
Vérifiez vos informations et cliquez sur Soumettre pour terminer la configuration.
</p>
</div>
)}
{/* Navigation */}
<div className="flex justify-between mt-6">
<button
type="button"
onClick={() => setCurrentStep((s) => Math.max(0, s - 1))}
className={`px-4 py-2 rounded-lg border ${
currentStep === 0 ? 'invisible' : ''
}`}
>
Précédent
</button>
{currentStep < steps.length - 1 ? (
<button
type="button"
onClick={() => setCurrentStep((s) => s + 1)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Suivant
</button>
) : (
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50"
>
{isPending ? 'Envoi en cours...' : 'Terminer la configuration'}
</button>
)}
</div>
</form>
</div>
)
}Étape 7 : Upload de fichiers avec les Server Actions
Les Server Actions gèrent nativement les uploads de fichiers via FormData :
// src/app/upload/actions.ts
'use server'
import { writeFile } from 'fs/promises'
import path from 'path'
export type UploadState = {
success: boolean
message: string
url?: string
}
export async function uploadAvatar(
prevState: UploadState,
formData: FormData
): Promise<UploadState> {
const file = formData.get('avatar') as File
if (!file || file.size === 0) {
return { success: false, message: 'Veuillez sélectionner un fichier' }
}
// Validation du type de fichier
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return { success: false, message: 'Seules les images JPEG, PNG et WebP sont autorisées' }
}
// Validation de la taille (max 5 Mo)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
return { success: false, message: 'Le fichier doit faire moins de 5 Mo' }
}
try {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
const uploadPath = path.join(process.cwd(), 'public', 'uploads', filename)
await writeFile(uploadPath, buffer)
return {
success: true,
message: 'Avatar uploadé avec succès !',
url: `/uploads/${filename}`,
}
} catch (error) {
return { success: false, message: "Échec de l'upload. Veuillez réessayer." }
}
}// src/app/upload/avatar-form.tsx
'use client'
import { useActionState, useRef } from 'react'
import { uploadAvatar, type UploadState } from './actions'
const initialState: UploadState = { success: false, message: '' }
export default function AvatarUploadForm() {
const [state, formAction, isPending] = useActionState(uploadAvatar, initialState)
const formRef = useRef<HTMLFormElement>(null)
return (
<form ref={formRef} action={formAction} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Photo de profil</label>
<input
name="avatar"
type="file"
accept="image/jpeg,image/png,image/webp"
required
className="block w-full text-sm file:mr-4 file:py-2 file:px-4
file:rounded-lg file:border-0 file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
</div>
{state.message && (
<div className={`px-4 py-3 rounded-lg text-sm ${
state.success
? 'bg-green-50 text-green-800'
: 'bg-red-50 text-red-800'
}`}>
{state.message}
</div>
)}
{state.url && (
<img
src={state.url}
alt="Avatar uploadé"
className="w-24 h-24 rounded-full object-cover"
/>
)}
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
{isPending ? 'Upload en cours...' : 'Uploader la photo'}
</button>
</form>
)
}Étape 8 : Pattern helper réutilisable pour les formulaires
Au fur et à mesure que votre application grandit, vous voudrez un pattern réutilisable. Voici un helper générique :
// src/lib/form-utils.ts
import { z } from 'zod'
export type ActionState<T = undefined> = {
success: boolean
message: string
errors?: Record<string, string[]>
data?: T
}
export function createFormAction<TSchema extends z.ZodObject<any>, TResult = void>(
schema: TSchema,
handler: (data: z.infer<TSchema>) => Promise<TResult>
) {
return async (
prevState: ActionState<TResult>,
formData: FormData
): Promise<ActionState<TResult>> => {
const raw = Object.fromEntries(formData.entries())
const result = schema.safeParse(raw)
if (!result.success) {
return {
success: false,
message: 'La validation a échoué',
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
}
}
try {
const data = await handler(result.data)
return { success: true, message: 'Succès', data: data as TResult }
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Une erreur s'est produite",
}
}
}
}Créer une nouvelle action de formulaire devient trivial :
// src/app/actions.ts
'use server'
import { createFormAction } from '@/lib/form-utils'
import { createUserSchema } from '@/lib/schemas'
export const createUser = createFormAction(createUserSchema, async (data) => {
// data est entièrement typé comme { name: string, email: string, password: string }
await db.user.create({ data })
return { id: crypto.randomUUID() }
})Tester votre implémentation
Lancez le serveur de développement :
npm run devTestez les scénarios suivants :
- Cas nominal — Remplissez tous les champs correctement et soumettez. Vous devriez voir un message de succès.
- Erreurs de validation — Soumettez avec un email invalide ou un mot de passe trop court. Des erreurs au niveau des champs devraient apparaître.
- JavaScript désactivé — Désactivez JS dans les DevTools et soumettez le formulaire. Il devrait fonctionner via un rechargement complet de la page.
- État de chargement — Cliquez sur soumettre et observez le bouton désactivé avec le texte "Création en cours...".
- Erreurs réseau — Simulez une panne serveur et vérifiez que le message d'erreur apparaît.
Dépannage
"Functions cannot be passed directly to Client Components"
Cette erreur signifie que vous essayez de passer une Server Action comme prop à un composant client de manière incorrecte. Assurez-vous que :
- La Server Action est définie dans un fichier
'use server' - Vous l'importez directement dans le composant client, ou la passez via la prop
actiondu formulaire
"useActionState is not a function"
Assurez-vous d'être sur React 19+. Vérifiez votre package.json — si vous voyez React 18.x, mettez à jour :
npm install react@latest react-dom@latestLes données du formulaire sont vides
Assurez-vous que chaque input a un attribut name. FormData utilise l'attribut name pour collecter les valeurs — sans lui, le champ est invisible pour le serveur.
Points clés
| Pattern | Quand l'utiliser |
|---|---|
action={serverAction} simple | Formulaires simples dans les composants serveur, pas besoin de retour |
useActionState | Formulaires nécessitant des états de chargement, erreurs et messages de succès |
useOptimistic | Retour instantané pour les actions (commentaires, likes, toggles) |
Helper createFormAction | Pattern réutilisable pour les actions validées dans toute l'app |
Prochaines étapes
- Explorez revalidatePath et revalidateTag pour rafraîchir les données en cache après les mutations
- Construisez une application CRUD complète combinant les Server Actions avec Prisma ou Drizzle ORM
- Ajoutez du rate limiting à vos Server Actions pour la production
- Combinez avec la bibliothèque next-safe-action pour encore plus de sécurité de typage
Conclusion
Les Server Actions et useActionState de React 19 représentent une simplification majeure dans la construction des formulaires. En déplaçant la logique de validation et de mutation vers le serveur, vous obtenez l'amélioration progressive gratuitement, éliminez des catégories entières de bugs côté client, et écrivez considérablement moins de code. Les patterns de ce tutoriel — des actions basiques aux mises à jour optimistes en passant par les helpers réutilisables — vous donnent une base solide pour construire des formulaires prêts pour la production dans n'importe quelle application Next.js.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Zod v4 avec Next.js 15 : Validation complète des schémas pour les formulaires, APIs et Server Actions
Maîtrisez Zod v4 dans Next.js 15 — validez les formulaires avec Server Actions, sécurisez les routes API, parsez les variables d'environnement et construisez des applications entièrement type-safe avec la bibliothèque de validation TypeScript la plus rapide.

Construire une Application Full-Stack avec Drizzle ORM et Next.js 15 : Base de Donnees Type-Safe du Zero a la Production
Apprenez a construire une application full-stack type-safe avec Drizzle ORM et Next.js 15. Ce tutoriel pratique couvre la conception de schemas, les migrations, les Server Actions, les operations CRUD et le deploiement avec PostgreSQL.

Construire un Agent IA Autonome avec Agentic RAG et Next.js
Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.