Écrire un formulaire React qui soit à la fois type-safe, validé proprement et lisible reste un exercice frustrant en 2026. TanStack Form apporte une réponse différente de react-hook-form : il s’appuie sur le standard Standard Schema, dérive les types depuis votre schéma Zod (ou Valibot, ou ArkType) et propose une API headless qui ne s’occupe ni du rendu ni du style. Vous gardez le contrôle total du JSX. Ce tutoriel construit pas à pas un formulaire d’inscription complet : champ texte, champ numérique, validation par schéma, états dérivés, soumission asynchrone, gestion d’erreurs serveur et soumission via une server function.
Article de la série autour de TanStack Start en production 2026.
Prérequis
Le tutoriel s’inscrit dans une application React 19 moderne. Si vous démarrez de zéro, suivez d’abord le tutoriel d’installation TanStack Start v1 puis revenez ici. Sinon, vérifiez les points suivants.
- Node.js 22 LTS et npm 10 ou plus récent.
- Un projet React 19 fonctionnel, avec TypeScript activé en
strict: true. - Un éditeur avec serveur TS actif pour bénéficier de l’inférence sur les noms de champs.
- Notions de base sur Zod et la composition des schémas.
Étape 1 — Installer TanStack Form et Zod
L’installation se résume à deux paquets. @tanstack/react-form apporte le hook useForm et le composant Field, zod fournit la validation déclarative. Standard Schema fait le pont entre les deux sans adapter intermédiaire. C’est plus léger qu’avec react-hook-form où il fallait souvent un resolver dédié.
npm install @tanstack/react-form zod
Après l’install, ouvrez package.json et confirmez que @tanstack/react-form est en version 1 stable et zod en version 4 ou supérieure. La version 4 de Zod améliore l’inférence et apporte des messages d’erreur i18n. Si vous restez en Zod 3, le tutoriel marche aussi mais la dernière étape sur la traduction des messages devra être adaptée.
Étape 2 — Définir le schéma Zod source de vérité
Avant de toucher à un seul composant, on écrit le schéma. C’est lui qui sera la source de vérité : les types des champs, les messages d’erreur, les contraintes métier, tout part de là. Le réflexe à acquérir consiste à modéliser d’abord la donnée que vous voulez recevoir côté serveur, puis à dériver l’UI ensuite, jamais l’inverse.
// app/schemas/user.ts
import { z } from 'zod'
export const userSchema = z.object({
email: z
.string()
.min(1, 'L\'email est requis')
.email('Format d\'email invalide'),
age: z
.number({ invalid_type_error: 'L\'âge doit être un nombre' })
.int()
.gte(13, 'Vous devez avoir au moins 13 ans'),
password: z.string().min(8, 'Au moins 8 caractères'),
})
export type UserInput = z.infer<typeof userSchema>
Le type UserInput est inféré une fois pour toutes : il vaut { email: string; age: number; password: string }. Vous l’importerez côté composant pour les valeurs par défaut et côté serveur pour le handler de la server function. Une seule définition, deux usages, zéro duplication.
Étape 3 — Initialiser le hook useForm
Le hook useForm est le centre nerveux du formulaire. Il prend des defaultValues typées, des validators qui se déclenchent à différents moments du cycle de vie, et un callback onSubmit qui reçoit la valeur déjà validée. La nouveauté en 2026 : on passe directement le schéma Zod aux validators, sans adapter intermédiaire.
// app/components/SignupForm.tsx
import { useForm } from '@tanstack/react-form'
import { userSchema } from '~/schemas/user'
export function SignupForm() {
const form = useForm({
defaultValues: { email: '', age: 0, password: '' },
validators: {
onChange: userSchema,
onSubmit: userSchema,
},
onSubmit: async ({ value }) => {
// value est typé { email: string; age: number; password: string }
console.log('Soumission validée :', value)
},
})
return null
}
Trois points méritent attention. onChange: userSchema revalide à chaque frappe — utile pour un retour immédiat sur les emails malformés. onSubmit: userSchema rejoue la validation au moment de la soumission, ce qui couvre les cas où le validateur onChange aurait été désactivé sur certains champs. Et onSubmit côté handler ne se déclenche jamais si la validation a échoué : pas besoin de défensif if (errors) en début.
Étape 4 — Rendre les champs avec form.Field
TanStack Form n’impose aucun rendu : c’est à vous de placer les input, les label, les messages d’erreur. Le composant form.Field est une render-prop qui injecte un objet field dans son enfant. Ce field contient la valeur, les handlers, les méta-données (touché, valide, erreurs).
function SignupForm() {
// ... useForm comme à l'étape 3
return (
<form
onSubmit={(e) => {
e.preventDefault()
void form.handleSubmit()
}}
>
<form.Field name="email">
{(field) => (
<div>
<label htmlFor={field.name}>Email</label>
<input
id={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{!field.state.meta.isValid && (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
)}
</div>
)}
</form.Field>
<button type="submit">S'inscrire</button>
</form>
)
}
Si vous tapez un email invalide, le message d’erreur apparaît dès la première frappe parce que le validateur onChange rejoue le schéma à chaque modification. Si vous laissez le champ vide et tabulez ailleurs, le handleBlur marque le champ comme touché et le message s’affiche aussi. Le typage du nom du champ est strict : tapez name="emial" et TypeScript hurle immédiatement, ce qui supprime cette catégorie de bugs.
Étape 5 — Ajouter un champ numérique et un mot de passe
Un champ numérique demande une attention particulière : la valeur d’un <input type="number"> reste une chaîne, et il faut convertir en nombre dans le handler. La propriété e.target.valueAsNumber donne le résultat directement. Pour le mot de passe, on utilise type="password" sans subtilité spéciale.
<form.Field name="age">
{(field) => (
<div>
<label htmlFor={field.name}>Âge</label>
<input
id={field.name}
type="number"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{!field.state.meta.isValid && (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
)}
</div>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<div>
<label htmlFor={field.name}>Mot de passe</label>
<input
id={field.name}
type="password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{!field.state.meta.isValid && (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
)}
</div>
)}
</form.Field>
Tapez 12 dans le champ âge : le message Vous devez avoir au moins 13 ans apparaît. Tapez 13 ou plus, il disparaît. Le mot de passe s’affiche en points. Si vous tabulez à travers tous les champs sans les remplir, chacun affiche son erreur après le blur. La validation tient en une cinquantaine de lignes au total, schémas inclus.
Étape 6 — Désactiver le bouton avec form.Subscribe
Activer le bouton de soumission seulement quand le formulaire est valide est un pattern UX évident, mais qui vous oblige souvent à abonner tout le composant à l’état du form. form.Subscribe sélectionne précisément l’état dont vous avez besoin et ne re-rend que ce sous-arbre. Le bouton se met à jour, le reste du formulaire reste stable.
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Envoi…' : 'S\'inscrire'}
</button>
)}
/>
Tant que le formulaire contient une erreur, canSubmit est false et le bouton reste grisé. Pendant la soumission, isSubmitting bascule à true et le label devient Envoi…. C’est aussi le moment où le bouton ignore les clics multiples, ce qui évite les doubles soumissions accidentelles sur connexions lentes.
Étape 7 — Soumettre vers une server function
Le retour onSubmit reçoit un objet value déjà validé. C’est le moment de l’envoyer côté serveur. Si votre projet utilise les server functions de Start (voir le tutoriel sur les RPC TanStack Start), l’appel devient un simple await typé.
// app/server/auth.ts
import { createServerFn } from '@tanstack/react-start'
import { userSchema } from '~/schemas/user'
export const signupServerFn = createServerFn({ method: 'POST' })
.inputValidator(userSchema)
.handler(async ({ data }) => {
// Hashage, insert en base, etc.
return { ok: true, userId: 'usr_' + Math.random().toString(36).slice(2) }
})
// Côté composant
import { signupServerFn } from '~/server/auth'
const form = useForm({
defaultValues: { email: '', age: 0, password: '' },
validators: { onChange: userSchema, onSubmit: userSchema },
onSubmit: async ({ value }) => {
const result = await signupServerFn({ data: value })
console.log('Inscrit :', result.userId)
},
})
Le même userSchema sert à valider côté client (UX) et côté serveur (sécurité). Si quelqu’un appelle directement la server function avec un payload bidouillé, Zod rejette la requête avant même d’atteindre la base. C’est la définition pratique de la defense in depth appliquée aux formulaires.
Étape 8 — Afficher une erreur serveur globale
La server function peut échouer pour des raisons hors du schéma : email déjà pris, base injoignable, rate limit. On veut afficher un message au-dessus du formulaire sans casser la validation des champs. La méthode propre consiste à attraper l’exception dans onSubmit et à pousser l’erreur dans un état React local — TanStack Form n’oblige rien.
import { useState } from 'react'
function SignupForm() {
const [serverError, setServerError] = useState<string | null>(null)
const form = useForm({
defaultValues: { email: '', age: 0, password: '' },
validators: { onChange: userSchema, onSubmit: userSchema },
onSubmit: async ({ value }) => {
setServerError(null)
try {
await signupServerFn({ data: value })
} catch (e) {
setServerError(e instanceof Error ? e.message : 'Erreur inconnue')
}
},
})
return (
<form onSubmit={(e) => { e.preventDefault(); void form.handleSubmit() }}>
{serverError && <div role="alert">{serverError}</div>}
{/* ... champs comme à l'étape 5 */}
</form>
)
}
Au prochain échec serveur, le message s’affiche au-dessus du formulaire sans interférer avec la validation des champs individuels. Si vous voulez injecter l’erreur dans un champ précis (par exemple cet email est déjà utilisé sur le champ email), regardez la méthode field.setMeta({ errors: [...] }) qui permet de pousser une erreur synthétique de l’extérieur.
Comparatif rapide TanStack Form vs react-hook-form
Les deux bibliothèques sont matures et largement utilisées. Le tableau suivant aide à choisir.
| Critère | TanStack Form | react-hook-form |
|---|---|---|
| Type-safety | Native, sans any |
Forte avec useForm<Type>() |
| Validation Zod | Standard Schema, sans adapter | Via @hookform/resolvers/zod |
| API | Render-prop form.Field |
Hooks register et Controller |
| Performance | Sélecteurs fins (Subscribe) |
Re-render isolé par champ |
| Async validation | Validators async natifs | Validators async via resolver |
| Écosystème | Émergent en 2026 | Très large |
| Bundle | ~10 Ko gzippé | ~9 Ko gzippé |
Pour un projet TanStack Start ou Router, TanStack Form s’aligne naturellement et partage le pattern de typage des autres briques. Pour un projet Next.js classique, react-hook-form reste plus documenté.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
field.state.value est undefined |
Pas de valeur par défaut pour ce champ | Ajouter le champ à defaultValues |
Type mismatch sur name |
Faute de frappe sur le nom du champ | S’appuyer sur l’autocomplétion TS |
| Champ numérique reçoit une chaîne | e.target.value au lieu de valueAsNumber |
Utiliser e.target.valueAsNumber |
| Bouton toujours cliquable | Pas de form.Subscribe sur canSubmit |
Brancher l’abonnement précis |
| Validation ne se déclenche pas | Validators non passés à useForm |
Renseigner validators.onChange ou onSubmit |
| Erreur serveur perdue | Pas de try/catch dans onSubmit |
Stocker l’erreur dans un useState local |
FAQ
Peut-on utiliser TanStack Form sans Zod ? Oui. Les validators acceptent aussi des fonctions classiques retournant undefined ou un message d’erreur. Mais l’intérêt principal de la bibliothèque tient dans l’inférence de types depuis un schéma, donc Zod ou Valibot reste le combo idiomatique.
Comment gérer un champ tableau (par exemple une liste de tags) ? Le composant form.Field accepte des chemins imbriqués (tags.0, tags.1), ou utilisez form.useField({ name: 'tags' }) pour manipuler l’array entier avec pushValue et removeValue.
Comment réinitialiser un formulaire après soumission ? form.reset() remet les valeurs par défaut. Appelez-le après l’await de la server function dans onSubmit.
Peut-on lier le formulaire à un état global (Zustand, Redux) ? Oui, mais ce n’est généralement pas utile. TanStack Form gère son propre état ; l’état global ne récupère la donnée qu’au moment de la soumission. Pour partager l’état UI entre composants, voyez le panorama state management React 2026.
La validation async fonctionne-t-elle (par exemple vérifier qu’un email est libre) ? Oui, via validators: { onChangeAsync: async ({ value }) => { ... return errorString } }. Pensez à debouncer côté composant pour éviter de frapper l’API à chaque touche.
Pour aller au-delà
- L’article principal : TanStack Start en production 2026.
- Pour la couche routage qui héberge les formulaires : file-based routing avec TanStack Router.
- Pour la soumission côté serveur : server functions TanStack Start.
- Pour la couche données après inscription : TanStack Query SSR et hydratation.
Ressources
- Documentation officielle TanStack Form : tanstack.com/form/latest/docs/framework/react/quick-start
- Guide validation avec schémas : tanstack.com/form/latest/docs/framework/react/guides/validation
- Documentation Zod : zod.dev
- Standard Schema : standardschema.dev