useActionState, le pont

La chaîne qui relie un formulaire à une fonction métier : la fonction que nous écrivons, celle que React fabrique pour le formulaire, l’état qu’il renvoie ; et dans le projet, pourquoi l’envoi est suspendu le temps de la validation.

Précédemment. React Hook Form a remis de l’ordre côté client, mais le client reste seul à orchestrer l’envoi, le réseau et l’interprétation des réponses. Le contrat avec le serveur est toujours à réécrire à la main.

Mouvement 2 — La réconciliation#

Comprendre la rupture ne suffit pas à la réparer. Ce mouvement est celui où nous allons reconnecter ce qui avait été séparé — le client et le serveur, l’expérience et la vérité, l’UX et le modèle. Pas en revenant en arrière, mais en combinant les outils modernes de React avec le contrat ancien du web.

Voici les trois étapes que nous allons traverser :

  • Section 4 — Nous découvrons useActionState, ce pont discret qui reconnecte une fonction métier à un <form> sans redevenir un gestionnaire d’événement fragile.
  • Section 5 — Nous voyons comment App Router change le paradigme : le serveur n’est plus « derrière une URL », il est directement la cible du formulaire, via les Server Actions.
  • Section 6 — Nous combinons les trois pièces — RHF, useActionState, Server Actions — dans un modèle hybride confronté au réel, où chaque couche tient enfin son rôle.

À la fin de ce mouvement, nous aurons un formulaire qui sait à nouveau parler au serveur sans trahir l’expérience utilisateur — et qui repose sur le modèle du web, augmenté, plutôt que contourné.


4 — Un nouveau pont : useActionState#

À ce stade, une nouvelle brique apparaît : useActionState.

Mais pour bien le comprendre, il faut éviter une confusion très fréquente :

👉 la fonction métier que nous écrivons n’est pas la même chose que la fonction que le <form> appelle réellement


Autrement dit :

  • submitLogin ❌ n’est pas appelée directement par le DOM
  • formAction ✅ est la fonction réellement branchée au <form>
  • useActionState 👉 fabrique ce pont entre les deux

👉 c’est ici que React commence à réconcilier son modèle avec le web


🧠 Le vrai modèle mental#

Un formulaire moderne n’est pas une simple fonction.

C’est une chaîne :

<form> → formAction → useActionState → submitLogin → serveur

👉 le DOM ne parle pas à notre fonction métier
👉 il parle à une fonction orchestrée par React


Et cette distinction change tout.


4.1 — La fonction métier : submitLogin#

Nous commençons par la fonction métier.

👉 son rôle n’est PAS de gérer le formulaire
👉 son rôle est de traiter des données

La fonction métier ne parle pas directement au DOM

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Un contrat d’état stable
TS
Exemple pédagogique
guided extract: lines 1-38focus line 1
export type LoginState = {  status: "idle" | "success" | "error"  message?: string}export const initialState: LoginState = {  status: "idle",}export async function submitLogin(  _prevState: LoginState,  formData: FormData): Promise<LoginState> {  const response = await fetch("http://localhost:4000/login", {    method: "POST",    body: JSON.stringify({      email: formData.get("email"),      password: formData.get("password"),    }),    headers: {      "Content-Type": "application/json",    },  })  const result = await response.json()  if (!response.ok) {    return {      status: "error",      message: result.message ?? "Erreur inconnue",    }  }  return {    status: "success",    message: "Connexion réussie",  }}

Cette fonction :

  • reçoit des données (FormData)
  • appelle un backend (ici via fetch)
  • transforme la réponse
  • retourne un état exploitable

👉 elle ne connaît pas le DOM
👉 elle ne connaît pas React
👉 elle ne connaît pas le <form>


👉 elle est indépendante


À ce stade, nous sommes encore dans un modèle hybride :

  • le client appelle encore fetch
  • la logique métier est côté client

👉 ce n’est PAS encore le modèle final


⚠️ Important : ce modèle est transitoire#

Ce que nous voyons ici ressemble encore à du React classique :

  • appel réseau manuel
  • transformation JSON
  • mapping erreur / succès

👉 c’est normal

👉 nous préparons le terrain


Parce que dans le modèle final :

👉 cette fonction deviendra une Server Action

👉 et le fetch disparaîtra complètement


👉 le serveur sera appelé directement par le <form>


4.2 — Le composant client : là où la magie devient explicable#

C’est ici que tout se joue.

Le composant ne relie pas directement le form à submitLogin

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

La fonction métier est importée, pas fusionnée au composant
TSX
Exemple pédagogique
guided extract: lines 1-48focus line 3
"use client"import { useActionState } from "react"import { useForm } from "react-hook-form"import {  submitLogin,  initialState,  type LoginState,} from "./login.action"type LoginValues = {  email: string  password: string}export function LoginForm() {  const [state, formAction, isPending] = useActionState<LoginState, FormData>(    submitLogin,    initialState  )  const {    register,    formState: { errors },  } = useForm<LoginValues>({    mode: "onBlur",  })  return (    <form action={formAction}>      <input {...register("email", { required: "Email requis" })} />      {errors.email && <p>{errors.email.message}</p>}      <input        type="password"        {...register("password", { required: "Mot de passe requis" })}      />      {errors.password && <p>{errors.password.message}</p>}      <button type="submit" disabled={isPending}>        {isPending ? "Connexion..." : "Se connecter"}      </button>      {state.status === "error" && <p>{state.message}</p>}      {state.status === "success" && <p>{state.message}</p>}    </form>  )}

La ligne clé :

La vraie ligne charnière

LoginForm.tsxts
Pattern recommandé
const [state, formAction, isPending] = useActionState(...)

👉 React prend notre fonction métier
👉 et fabrique une fonction spéciale : formAction


👉 cette fonction :

  • est compatible avec le <form> HTML
  • respecte le contrat du navigateur
  • appelle ensuite notre fonction métier

🔗 Le retour du modèle natif… augmenté#

Regardons cette ligne :

Le form parle à formAction

LoginForm.tsxtsx
Pattern recommandé
<form action={formAction}>

👉 ce n’est pas un hasard


Nous revenons à :

Le contrat natif du web

demo.htmlhtml
Exemple pédagogique
<form action="/login">

Mais avec une différence énorme :

👉 formAction n’est pas une URL
👉 c’est une fonction orchestrée par React


👉 nous retrouvons le web natif
👉 mais enrichi par React


Avec useActionState, React ne remplace plus le <form>.

👉 il le prolonge

  • le navigateur gère toujours la soumission
  • React orchestre ce qui se passe ensuite
  • le serveur reste la destination finale

👉 nous ne simulons plus le web
👉 nous nous appuyons dessus


🧩 Les responsabilités sont enfin claires#

À ce stade, nous pouvons découper proprement :

  • submitLogin → logique métier
  • useActionState → orchestration
  • formAction → interface avec le DOM
  • <form> → déclencheur natif

👉 chaque couche a un rôle précis


⚖️ Et React Hook Form dans tout ça ?#

RHF est toujours là :

  • validation locale
  • UX
  • gestion des inputs

Mais :

👉 il ne gère pas la soumission réelle
👉 il ne parle pas au serveur


👉 il complète useActionState, il ne le remplace pas


🧱 Ce que React Hook Form ne bloque pas#

Relisons le LoginForm pédagogique avec un œil plus sévère. React Hook Form y affiche bien ses erreurs (Email requis) quand l'utilisateur quitte un champ vide. Mais rien, dans ce composant, n'empêche l'envoi : le <form> est branché sur formAction, et React l'appelle dès que le bouton est cliqué. Les règles de register sont vérifiées quand un champ perd le focus, pour afficher l'erreur ; seul handleSubmit les vérifie avant d'envoyer, et handleSubmit n'est appelé nulle part.

👉 le formulaire affiche l'erreur… et part quand même


Le projet a rencontré ce piège, et l'a réglé dans chaque formulaire qui valide côté client avant une server action : catégorie, origine, famille, création et modification de galerie. (Le formulaire de profil, lui, ne valide qu'au serveur : il n'a rien à bloquer, nous le verrons au chapitre 6.) Voici le même montage dans le formulaire d'une catégorie du tableau de bord :

apps/web/src/features/admin/categories/forms/CategoryForm.tsx · extrait
tsx
  const [state, formAction, isPending] = useActionState<    SaveCategoryFormState,    FormData  >(saveCategoryFormAction, {} as SaveCategoryFormState);

C'est la ligne charnière de la section 4.2, dans un vrai formulaire : saveCategoryFormAction est la fonction métier, formAction la fonction que React fabrique pour le <form>, isPending l'état d'envoi. L'état initial ({}) est vide : tant que rien n'est envoyé, il n'y a ni succès ni erreur à afficher.

Le <form>, lui, ne se contente plus de action={formAction} :

apps/web/src/features/admin/categories/forms/CategoryForm.tsx · extrait
tsx
      <form        action={formAction}        onSubmit={(e) => {          // La validation du client est asynchrone : on suspend TOUJOURS l'envoi (un          // preventDefault après un await arriverait trop tard, l'action serait déjà partie),          // puis on le relance si le formulaire est valide.          e.preventDefault();          const formData = new FormData(e.currentTarget);          void form.trigger().then((valid) => {            if (valid) startTransition(() => formAction(formData));          });        }}        className="max-w-md space-y-4"      >        {/* Présent uniquement en édition → fait basculer l'action sur update. */}        {initial && <input type="hidden" name="id" value={initial.id} />}

La validation de React Hook Form est asynchrone : form.trigger() renvoie une promesse. Or le navigateur envoie le formulaire dès que le gestionnaire onSubmit se termine, s'il n'a pas appelé preventDefault(). Un preventDefault() placé après un await arriverait trop tard : l'action serait déjà partie.

Le composant suspend donc toujours l'envoi, valide, puis relance l'action lui-même :

  • e.preventDefault() arrête l'envoi natif, immédiatement, avant toute attente ;
  • new FormData(e.currentTarget) lit les champs tout de suite, tant que l'événement est disponible ;
  • form.trigger() lance la validation de React Hook Form ;
  • si elle passe, formAction(formData) relance l'action, dans une startTransition : c'est ce qui garde isPending exact, comme si le formulaire était parti tout seul.

L'attribut action={formAction} reste en place. Sans JavaScript (une page encore en chargement, un navigateur qui l'a désactivé), le <form> s'envoie quand même, à la manière du web d'origine, et c'est le serveur qui valide.

Les deux dernières lignes montrent un autre avantage du modèle : un même formulaire pour créer et modifier. En édition seulement, un champ caché id est ajouté. L'action le voit arriver, et bascule de la création à la mise à jour.

Dans un gestionnaire d'événement, tout ce qui doit empêcher le comportement par défaut se fait avant le premier await. Après, l'événement est déjà traité, et le navigateur a déjà fait ce qu'il avait à faire.


🧠 Le point clé (et le plus souvent mal compris)#

Le <form> n’appelle pas notre fonction.

👉 il appelle une fonction générée par React


Et cette fonction :

👉 garantit le bon passage entre :

  • DOM
  • React
  • serveur

👉 c’est ça, la vraie valeur de useActionState


🔥 Et maintenant, le vrai saut arrive#

À ce stade, nous avons :

  • un formulaire structuré
  • une orchestration propre
  • une séparation claire

Mais il reste encore une incohérence :

👉 le client fait encore un fetch


Dans la prochaine étape :

👉 nous supprimons complètement ce fetch

👉 et le serveur devient directement la cible du <form>


👉 c’est là que les Server Actions entrent en jeu


useActionState n’est pas la destination finale.

👉 c’est un pont

  • entre React et le web natif
  • entre le client et le serveur
  • entre une fonction métier et un <form>

👉 il prépare le terrain pour les Server Actions


👉 Où nous en sommes : useActionState n’est pas un outil de plus — c’est un pont. Il prend une fonction métier et la rebranche sur un <form> réel, sans que nous ayons à jouer nous-mêmes le rôle du navigateur. Mais ce pont ne prend toute sa valeur que dans un monde où le serveur peut vraiment être au bout — pas derrière une URL à construire, mais directement là. Reste à voir ce que change App Router pour que cette promesse tienne.

À suivre#

Le pont existe, mais sa fonction métier appelle encore fetch. Pour que le serveur soit vraiment au bout du formulaire, il faut un framework capable d’appeler une fonction serveur directement depuis un <form>.

Chapitre suivant : les server actions. Ce que l’App Router change, une action du projet lue ligne par ligne, pourquoi elle est une porte ouverte, et comment elle signale un refus.

Sur cette page