Le modèle hybride face au réel
Quand le backend est externe et que fetch redevient nécessaire : la différence entre un mauvais modèle et un modèle contraint, les contrats partagés et leurs limites, la hiérarchie de vérité, et un contre-exemple réel tiré du formulaire de connexion du projet.
Précédemment. Avec l’App Router, le formulaire appelle directement une fonction serveur. Une action du projet vérifie ses droits, valide avec le schéma partagé, écrit avec Prisma et renvoie un état. Le modèle tient — dans le contexte qui l’a vu naître.
6 — RHF, useActionState et server actions : renouer avec le modèle d’origine du web#
Jusqu’ici, nous avons construit quelque chose de solide.
Mais si nous sommes honnêtes — vraiment honnêtes — il reste une zone floue.
👉 Nous avons présenté un modèle… mais nous ne l’avons pas encore confronté au réel.
Et c’est exactement là que tout se joue.
🧠 Le moment où nous devenons rigoureux#
Nous pourrions nous arrêter ici et dire :
👉 “voilà le bon modèle”
Mais si nous faisons ça :
👉 nous mentons un peu
Parce que ce modèle :
👉 n’est pas universel
Et ça, c’est une bascule importante.
⚠️ 6.1 — Le cas réel : quand notre architecture nous contraint#
Reprenons ensemble.
Jusqu’ici, nous avons travaillé dans un contexte très particulier :
- monorepo
- backend accessible
- Server Actions possibles
👉 donc nous pouvions faire :
- logique métier côté serveur
- accès direct à Prisma
- zéro fetch manuel
Mais maintenant, posons-nous une vraie question.
👉 Et si nous n’avions PAS ce luxe ?
🧠 Situation réelle (que nous avons tous rencontrée)#
Nous sommes dans un projet où :
- le backend est externe
- l’API est publique
- le frontend est découplé
Exemple :
- une API REST fournie par un service tiers
- ou un backend maintenu par une autre équipe
Et là, nous nous rendons compte de quelque chose de très concret :
👉 nous ne pouvons PAS appeler une Server Action interne
👉 nous DEVONS passer par HTTP
🔥 Donc soyons clairs#
👉 fetch redevient normal
👉 fetch redevient nécessaire
Le problème n’a jamais été fetch.
👉 Le problème commence quand le client devient une autorité métier.
⚔️ Mauvais modèle vs modèle contraint#
Ici, il faut être très précis.
Parce que tout n’est pas “mauvais” ou “bon”.
❌ Mauvais modèle (ce que nous avons tous fait au début)#
Nous nous retrouvons avec un client qui :
- valide des règles métier
- décide de ce qui est correct ou non
- reconstruit la vérité
- interprète les erreurs serveur
👉 autrement dit :
👉 nous transformons le frontend en backend déguisé
⚠️ Modèle contraint (réalité propre)#
Dans un contexte API publique :
👉 nous acceptons la contrainte
Le client :
- envoie les données (
fetch) - affiche le résultat
Le serveur :
- valide
- décide
- persiste
👉 le client reste un consommateur
Et là, tout devient plus clair.
👉 le problème n’est pas l’outil
👉 c’est la répartition des responsabilités
6.2 — Réduire la casse (ce que nous ferions dans la vraie vie)#
Quand nous ne pouvons pas utiliser Server Actions, nous ne cherchons pas la perfection.
👉 nous cherchons à limiter la divergence
1 — Partager un contrat
Contrat partagé (Zod + TS)
import { z } from "zod"export const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(8),})export type LoginInput = z.infer<typeof LoginSchema>👉 frontend et backend parlent la même langue
Mais soyons lucides.
👉 ce contrat :
- n’exécute rien
- ne valide rien côté serveur
👉 c’est une projection
🔎 Dans le projet : packages/contracts/src/forms
Ce contrat partagé, le projet l'a, même s'il utilise des server actions : il vit dans le paquet packages/contracts, que le navigateur et le serveur importent tous les deux. Le schéma de la catégorie, par exemple :
import { z } from "zod";/** * Schéma unifié création + édition. * - `id` ABSENT → création * - `id` PRÉSENT → édition * * `id` est `z.coerce.number()` car il provient d'un input caché (string dans * le FormData) et il est EXCLU du resolver client via `.pick({ type: true })`. * Le champ saisi (`type`) est le seul validé côté client ; l'action valide le * schéma complet. */export const saveCategoryFormSchema = z.object({ id: z.coerce.number().int().positive().optional(), type: z.string().trim().min(1, "Le type est obligatoire."),});export type SaveCategoryFormValues = z.infer<typeof saveCategoryFormSchema>;Il est utilisé deux fois, de deux façons différentes :
- côté client, par le résolveur de React Hook Form, mais en partie seulement :
.pick({ type: true })ne garde quetype, caridvient d'un champ caché que l'utilisateur ne saisit pas ; - côté serveur, par l'action, en entier,
idcompris.
Les messages d'erreur sont écrits dans le schéma, en français, pour l'utilisateur final : ce sont eux qui s'affichent sous les champs, qu'ils viennent du navigateur ou du serveur.
Et la remarque précédente vaut ici aussi : ce fichier n'exécute rien. Il n'empêche rien tant que l'action ne l'appelle pas. Sa seule garantie est que, quand les deux côtés l'utilisent, ils appliquent la même règle.
2 — Génération via OpenAPI
Extrait OpenAPI
paths: /login: post: requestBody: content: application/json: schema: $ref: "#/components/schemas/LoginInput"👉 nous automatisons
👉 nous sécurisons le transport
Mais encore une fois :
👉 ça ne remplace PAS la logique serveur
3 — SDK backend
- client npm
- fonctions typées
- fetch encapsulé
👉 meilleure DX
👉 même contrainte fondamentale
6.3 — Hiérarchie de vérité (le vrai déclic)#
C’est ici que nous devons être très clairs entre nous.
🧱 Cas Server Actions (idéal)
- Prisma = vérité
- Server Action = point d’entrée
- client = interface
🌐 Cas API publique (contraint)
- backend = vérité
- contrat = projection
- client = consommateur
👉 phrase que nous devons retenir ensemble :
Le client ne doit jamais être la source de vérité métier.
👉 il doit consommer une vérité définie ailleurs
6.4 — Contre-exemple réel : quand nous dérapons#
Regardons un cas que nous avons tous écrit à un moment.
Anti-pattern : le client devient backend
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
const handleSubmit = async () => { if (!email.includes("@")) { setError("Email invalide") return } const res = await fetch("/api/login", { method: "POST", body: JSON.stringify({ email, password }), }) if (res.status === 403) { setError("Accès refusé") }}👉 ce code fonctionne
👉 mais il est instable structurellement
🔎 Le même dérapage, dans le projet — et sa correction
Ce contre-exemple n'est pas inventé pour l'occasion. Le formulaire de connexion du site en a longtemps été une version réelle : un état contrôlé, et une validation écrite à la main dans le composant, avec ses propres messages, en anglais.
La règle, écrite dans le composant
const validationMessage = useMemo(() => { // … if (!isValidEmail(emailTrimmed)) { return 'Please enter a valid email address.'; } // … if (password.length < 12) { return 'Password must be at least 12 characters long.'; } return null;}, [emailTrimmed, password]);La règle « au moins 12 caractères » existait pourtant déjà ailleurs. Dans la procédure de connexion, avec un autre message, en français :
La même règle, réécrite dans la procédure
login: publicProcedure .input( z.object({ email: z.string().email(), password: z.string().min(12, "Le mot de passe doit avoir au moins 12 caractères"), }) )Et une troisième fois dans packages/contracts, dans un schéma authSchema prévu pour être partagé… que personne n'importait. La procédure de réinitialisation, la page « Mot de passe oublié » et la page du nouveau mot de passe avaient chacune leur copie, de la règle des 12 caractères ou de celle de l'adresse e-mail.
Le schéma partagé existait, il était juste, et il ne servait à rien : c'est la remarque de la section 6.2 (« ce contrat n'exécute rien ») prise sur le fait. Le jour où la longueur minimale serait passée à 14 caractères, il aurait fallu penser à six fichiers. Un seul oubli, et deux scénarios étaient possibles :
- le formulaire exige 12, la procédure 14 : l'utilisateur voit un formulaire « valide » refusé par le serveur, avec un message qu'il n'a jamais vu ;
- le formulaire exige 14, la procédure 12 : rien ne casse, mais la règle affichée n'est plus celle du système.
Chaque copie avait été écrite de bonne foi, à un moment différent. C'est ainsi qu'une duplication s'installe : jamais d'un coup.
La correction suit la hiérarchie de vérité de la section 6.3. Les règles ne vivent plus qu'à un endroit :
/** Longueur minimale d'un mot de passe. Les textes d'aide l'affichent à partir de cette valeur. */export const PASSWORD_MIN_LENGTH = 12;/** * Une adresse e-mail (connexion, mot de passe oublié, création d'un compte). Toujours en minuscules : * les adresses sont enregistrées ainsi, et une adresse tapée avec une majuscule retrouve son compte. */export const emailSchema = z .string() .trim() .toLowerCase() .min(1, "Saisissez votre adresse e-mail.") .email("Adresse e-mail invalide.");/** Un mot de passe choisi par le membre (réinitialisation, changement). */export const passwordSchema = z .string() .min(PASSWORD_MIN_LENGTH, `Le mot de passe doit faire au moins ${PASSWORD_MIN_LENGTH} caractères.`);/** * Connexion. Un champ vide reçoit son propre message, avant les règles de forme : Zod renvoie les * problèmes dans l'ordre des vérifications, et le formulaire affiche le premier. */export const loginSchema = z.object({ email: emailSchema, password: z.string().min(1, "Saisissez votre mot de passe.").pipe(passwordSchema),});La procédure applique le schéma tel quel :
login: publicProcedure // Même schéma que le formulaire de connexion (packages/contracts) : une seule règle, un seul message. .input(loginSchema)Et le formulaire l'applique aussi, sans rien réécrire :
// Les règles viennent du schéma partagé avec la procédure auth.login : le formulaire affiche la // même règle, avec le même message, que celle que le serveur appliquera. const parsed = loginSchema.safeParse({ email, password }); const issues = parsed.success ? [] : parsed.error.issues; const validationMessage = issues[0]?.message ?? null; const emailInvalid = issues.some((i) => i.path[0] === "email"); const passwordInvalid = issues.some((i) => i.path[0] === "password"); const isFormValid = validationMessage === null;safeParse ne lève pas d'erreur : il renvoie la liste des problèmes, dans l'ordre où le schéma les vérifie. Le formulaire affiche le premier, et marque comme invalide chaque champ concerné. Les messages sont désormais ceux du schéma, en français, identiques des deux côtés. La longueur minimale est une constante, PASSWORD_MIN_LENGTH, que les textes d'aide de la page du nouveau mot de passe affichent au lieu d'écrire « 12 » en dur.
Le formulaire garde son état contrôlé plutôt que de passer à React Hook Form : le bouton reste désactivé tant que la saisie n'est pas valide, et son info-bulle donne la raison. Ce qui comptait n'était pas l'outil, mais la source de la règle.
🧠 Conclusion — version adulte (celle que nous gardons)#
Nous pourrions résumer ça simplement.
Mais ce serait dommage.
👉 Ce n’est pas React qui est le problème
👉 Ce n’est pas fetch qui est le problème
👉 Le vrai problème, c’est :
👉 où vit la vérité
Ce que nous gardons ensemble#
👉 Quand nous pouvons :
- Server Actions
- logique serveur
- modèle centralisé
👉 nous le faisons
👉 Quand nous ne pouvons pas :
- API publique
- backend séparé
👉 nous structurons :
- contrats
- validation serveur
- client passif
Un bon système ne dépend pas des outils.
👉 il dépend de la bonne répartition des responsabilités
Et si nous devions garder une seule idée de toute cette section :
👉 ce n’est pas une question de “comment envoyer un formulaire”
👉 c’est une question de :
👉 qui a le droit de décider
👉 Où nous en sommes : le modèle hybride tient. Nous avons vu chaque pièce à sa place — RHF pour l’expérience, useActionState pour le pont, Server Actions pour la vérité — et nous avons confronté ce modèle aux contraintes du réel sans qu’il s’effondre. Mais un modèle qui fonctionne toujours n’apprend rien sur ses propres limites. Reste à changer de posture, et à regarder ce même modèle pour y trouver ce qui casse — parce que ce qui semble trop solide cache souvent ses pièges les plus dangereux.
Ce que nous venons de construire dans le Mouvement 2#
Nous avons réparé la fracture. Trois idées en sont ressorties :
useActionStateest un pont, pas une destination. Il ne remplace pas le contrat natif du web, il l’augmente — en permettant à une fonction métier de se brancher à un<form>sans reconstruire à la main tout ce que le navigateur faisait déjà. C’est le chaînon qui manquait pour reconnecter React au modèle ancien sans renoncer à ses forces.- App Router change la nature du serveur dans le code. Les Server Actions transforment le serveur d’une « URL à appeler » en une fonction directement référencée depuis le formulaire. Le fetch manuel disparaît, le formulaire retrouve sa cible naturelle, et la plomberie réseau sort du code applicatif.
- Le modèle hybride redistribue les responsabilités clairement. RHF tient l’UX et la validation instantanée ;
useActionStatetient la traduction vers le<form>; la Server Action tient la logique métier et revalide tout ce qui vient du client. Chacun fait ce qu’il fait le mieux — et surtout, plus rien ne fait ce qu’il ne devrait pas.
Le Mouvement 3 va maintenant regarder ce modèle sous un autre angle : celui de ses limites. Parce qu’un pattern vraiment mature ne se juge pas sur ce qu’il permet de faire, mais sur ce qu’il nous interdit de faire mal — et sur ce qu’il ne peut pas du tout faire.
À suivre#
Le modèle hybride a résisté à la confrontation. C’est justement le moment de changer de posture : un modèle qui donne confiance cache ses pièges les plus dangereux là où il semble le plus solide.
Chapitre suivant : les pièges du modèle hybride. La fausse sécurité du client, useActionState détourné, le FormData qui ment, l’interface qui affiche une vérité périmée, et la duplication qu’il faut garder.