TanStack Form v1 avec Next.js 15 : Tutoriel complet de formulaires type-safe

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Les formulaires sont omniprésents dans les applications web, pourtant les construire correctement reste étonnamment difficile. Gestion d'état, validation, accessibilité, intégration serveur et performance convergent vers l'une des surfaces les plus sujettes aux erreurs d'une base de code. TanStack Form v1 a été conçu pour résoudre ce problème avec des primitives headless et agnostiques au framework, entièrement type-safe du nom du champ jusqu'à la charge utile soumise.

Dans ce tutoriel, vous allez construire un formulaire multi-étapes complet dans Next.js 15 App Router en utilisant TanStack Form v1. Vous implémenterez la validation synchrone et asynchrone avec Zod, des tableaux de champs dynamiques, l'intégration avec les server actions de Next.js, et un flux de soumission prêt pour la production.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20 ou plus récent installé
  • Une familiarité avec Next.js App Router et React Server Components
  • Une connaissance pratique des hooks React et des generics TypeScript
  • Un éditeur de code tel que VS Code
  • Un gestionnaire de paquets npm, pnpm ou bun

Ce que vous allez construire

Un formulaire de candidature avec trois sections :

  1. Informations personnelles avec validation synchrone
  2. Expérience professionnelle sous forme de tableau de champs dynamique
  3. Liens de portfolio avec validation asynchrone des URL

Le formulaire persiste la progression, se soumet via une server action Next.js et gère les erreurs avec élégance.

Étape 1 : Configuration du projet

Créez une nouvelle application Next.js et installez les dépendances.

npx create-next-app@latest tanstack-form-demo --typescript --app --tailwind --no-src-dir
cd tanstack-form-demo
npm install @tanstack/react-form @tanstack/zod-form-adapter zod

TanStack Form est distribué en paquets séparés afin que vous ne regroupiez que ce dont vous avez besoin. La liaison React est @tanstack/react-form, et @tanstack/zod-form-adapter fournit le pont de validation Zod.

Vérifiez l'installation en démarrant le serveur de développement.

npm run dev

Étape 2 : Définir le schéma du formulaire avec Zod

Le typage fort commence par le schéma. Créez lib/schemas/application.ts.

import { z } from "zod"
 
export const ExperienceSchema = z.object({
  company: z.string().min(1, "Le nom de l'entreprise est requis"),
  role: z.string().min(1, "Le poste est requis"),
  years: z.number().min(0).max(50),
})
 
export const ApplicationSchema = z.object({
  fullName: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
  email: z.string().email("Adresse email invalide"),
  phone: z.string().regex(/^\+?[0-9\s-]{8,}$/, "Numéro de téléphone invalide"),
  experience: z.array(ExperienceSchema).min(1, "Ajoutez au moins une expérience"),
  portfolio: z.array(z.string().url("Doit être une URL valide")).optional(),
  coverLetter: z.string().min(50, "La lettre de motivation doit comporter au moins 50 caractères"),
})
 
export type Application = z.infer<typeof ApplicationSchema>
export type Experience = z.infer<typeof ExperienceSchema>

Le schéma devient la source unique de vérité. TanStack Form l'utilisera pour valider les champs et inférer automatiquement tous les types du formulaire.

Étape 3 : Créer un composant de champ réutilisable

TanStack Form est headless, donc vous contrôlez chaque morceau de balisage. Créez components/form/TextField.tsx.

import type { AnyFieldApi } from "@tanstack/react-form"
 
interface TextFieldProps {
  field: AnyFieldApi
  label: string
  type?: string
  placeholder?: string
}
 
export function TextField({ field, label, type = "text", placeholder }: TextFieldProps) {
  const errors = field.state.meta.errors
  const hasError = field.state.meta.isTouched && errors.length > 0
 
  return (
    <div className="flex flex-col gap-1">
      <label htmlFor={field.name} className="text-sm font-medium">
        {label}
      </label>
      <input
        id={field.name}
        name={field.name}
        type={type}
        placeholder={placeholder}
        value={field.state.value ?? ""}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        aria-invalid={hasError}
        aria-describedby={hasError ? `${field.name}-error` : undefined}
        className="rounded border px-3 py-2 focus:ring-2 focus:ring-blue-500"
      />
      {hasError && (
        <span id={`${field.name}-error`} className="text-sm text-red-600">
          {errors.map((err: { message?: string } | string) =>
            typeof err === "string" ? err : err.message
          ).join(", ")}
        </span>
      )}
    </div>
  )
}

Notez trois choses : les attributs aria-invalid et aria-describedby pour les lecteurs d'écran, le gestionnaire onBlur qui marque le champ comme touché, et le fait que les erreurs ne sont affichées qu'après interaction de l'utilisateur avec le champ. Ces trois modèles préviennent les bugs d'accessibilité les plus courants dans les formulaires React.

Étape 4 : Construire le formulaire de candidature

Créez app/apply/page.tsx.

"use client"
 
import { useForm } from "@tanstack/react-form"
import { zodValidator } from "@tanstack/zod-form-adapter"
import { ApplicationSchema } from "@/lib/schemas/application"
import { TextField } from "@/components/form/TextField"
import { submitApplication } from "./actions"
 
export default function ApplyPage() {
  const form = useForm({
    defaultValues: {
      fullName: "",
      email: "",
      phone: "",
      experience: [{ company: "", role: "", years: 0 }],
      portfolio: [],
      coverLetter: "",
    },
    validatorAdapter: zodValidator(),
    validators: {
      onChange: ApplicationSchema,
    },
    onSubmit: async ({ value }) => {
      const result = await submitApplication(value)
      if (!result.success) {
        throw new Error(result.error)
      }
    },
  })
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
      className="mx-auto max-w-2xl space-y-6 p-6"
    >
      <h1 className="text-2xl font-bold">Candidature</h1>
 
      <form.Field name="fullName">
        {(field) => <TextField field={field} label="Nom complet" />}
      </form.Field>
 
      <form.Field name="email">
        {(field) => <TextField field={field} label="Email" type="email" />}
      </form.Field>
 
      <form.Field name="phone">
        {(field) => <TextField field={field} label="Téléphone" type="tel" />}
      </form.Field>
    </form>
  )
}

Le composant form.Field utilise le pattern render props. À l'intérieur de la fonction de rendu, field vous donne tout ce dont vous avez besoin : valeur actuelle, erreurs, gestionnaires et état au niveau du champ. Comme useForm a reçu defaultValues, TypeScript infère la forme et générera une erreur à la compilation si vous tapez form.Field name="fullname" avec la mauvaise casse.

Étape 5 : Ajouter des tableaux de champs dynamiques

L'expérience professionnelle est une liste qui grandit et rétrécit. TanStack Form offre une prise en charge de premier ordre pour cela via form.Field avec l'option mode="array".

Ajoutez sous le champ téléphone.

<form.Field name="experience" mode="array">
  {(field) => (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h2 className="text-lg font-semibold">Expérience professionnelle</h2>
        <button
          type="button"
          onClick={() =>
            field.pushValue({ company: "", role: "", years: 0 })
          }
          className="rounded bg-blue-600 px-3 py-1 text-white"
        >
          Ajouter une expérience
        </button>
      </div>
 
      {field.state.value.map((_, index) => (
        <div key={index} className="space-y-2 rounded border p-4">
          <form.Field name={`experience[${index}].company`}>
            {(subField) => <TextField field={subField} label="Entreprise" />}
          </form.Field>
          <form.Field name={`experience[${index}].role`}>
            {(subField) => <TextField field={subField} label="Poste" />}
          </form.Field>
          <form.Field name={`experience[${index}].years`}>
            {(subField) => (
              <TextField field={subField} label="Années" type="number" />
            )}
          </form.Field>
          <button
            type="button"
            onClick={() => field.removeValue(index)}
            className="text-sm text-red-600"
          >
            Supprimer
          </button>
        </div>
      ))}
    </div>
  )}
</form.Field>

Les méthodes pushValue et removeValue modifient le tableau tout en préservant la sécurité des types. Chaque champ imbriqué est typé selon le schéma, donc experience[0].years est connu comme étant un nombre.

Étape 6 : Validation asynchrone pour les URL de portfolio

Parfois, la validation nécessite un appel réseau. Pour les URL de portfolio, vous voulez vérifier que le lien se résout effectivement. TanStack Form prend en charge les validateurs asynchrones par champ.

Ajoutez la section portfolio au formulaire.

<form.Field
  name="portfolio"
  mode="array"
  validators={{
    onChangeAsync: async ({ value }) => {
      if (!value || value.length === 0) return undefined
      const checks = await Promise.all(
        value.map(async (url) => {
          try {
            const res = await fetch(`/api/check-url?url=${encodeURIComponent(url)}`)
            const data = await res.json()
            return data.ok ? null : `${url} est inaccessible`
          } catch {
            return `Impossible de vérifier ${url}`
          }
        })
      )
      const errors = checks.filter(Boolean)
      return errors.length > 0 ? errors.join(", ") : undefined
    },
    onChangeAsyncDebounceMs: 500,
  }}
>
  {(field) => (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">Liens de portfolio</h2>
      {field.state.value?.map((_, index) => (
        <form.Field key={index} name={`portfolio[${index}]`}>
          {(sub) => <TextField field={sub} label={`URL ${index + 1}`} />}
        </form.Field>
      ))}
      <button
        type="button"
        onClick={() => field.pushValue("")}
        className="rounded border px-3 py-1"
      >
        Ajouter une URL
      </button>
    </div>
  )}
</form.Field>

L'option onChangeAsyncDebounceMs empêche de marteler le serveur pendant que l'utilisateur tape. TanStack Form gère le debouncing, les conditions de course et l'annulation en interne.

Créez la route API dans app/api/check-url/route.ts.

import { NextResponse } from "next/server"
 
export async function GET(request: Request) {
  const url = new URL(request.url).searchParams.get("url")
  if (!url) return NextResponse.json({ ok: false })
 
  try {
    const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3000) })
    return NextResponse.json({ ok: res.ok })
  } catch {
    return NextResponse.json({ ok: false })
  }
}

Étape 7 : Intégrer avec les Server Actions Next.js

Le formulaire se soumet via une server action. Créez app/apply/actions.ts.

"use server"
 
import { ApplicationSchema, type Application } from "@/lib/schemas/application"
import { revalidatePath } from "next/cache"
 
export async function submitApplication(data: Application) {
  const parsed = ApplicationSchema.safeParse(data)
  if (!parsed.success) {
    return {
      success: false as const,
      error: "Données de candidature invalides",
      issues: parsed.error.flatten(),
    }
  }
 
  try {
    await saveToDatabase(parsed.data)
    revalidatePath("/apply")
    return { success: true as const }
  } catch (error) {
    const message = error instanceof Error ? error.message : "Erreur inconnue"
    return { success: false as const, error: message }
  }
}
 
async function saveToDatabase(data: Application) {
  console.info("Enregistrement de la candidature", data.email)
}

Revalidez toujours côté serveur même si le client a déjà validé. Le client n'est jamais la source de vérité.

Étape 8 : Bouton de soumission avec Subscribe

TanStack Form utilise un modèle d'abonnement à granularité fine. Seuls les composants qui lisent une partie de l'état se re-rendront lorsque cette partie change. Utilisez form.Subscribe pour observer l'état du bouton de soumission.

<form.Subscribe
  selector={(state) => [state.canSubmit, state.isSubmitting]}
>
  {([canSubmit, isSubmitting]) => (
    <button
      type="submit"
      disabled={!canSubmit}
      className="w-full rounded bg-blue-600 py-3 font-semibold text-white disabled:opacity-50"
    >
      {isSubmitting ? "Envoi en cours..." : "Envoyer la candidature"}
    </button>
  )}
</form.Subscribe>

Le reste du formulaire ne se re-rend pas lorsque isSubmitting bascule. Cela compte quand les formulaires dépassent 20 ou 30 champs, car React réconcilierait autrement l'arbre entier à chaque frappe.

Étape 9 : Persister la progression dans localStorage

Les utilisateurs s'attendent à ce que les longs formulaires survivent à un rafraîchissement. Utilisez form.Subscribe pour persister l'état.

Ajoutez à l'intérieur du composant page après useForm.

import { useEffect } from "react"
 
useEffect(() => {
  const stored = localStorage.getItem("application-draft")
  if (stored) {
    try {
      form.reset(JSON.parse(stored))
    } catch {
      console.warn("Échec de la restauration du brouillon")
    }
  }
}, [form])
 
useEffect(() => {
  const unsubscribe = form.store.subscribe(() => {
    localStorage.setItem("application-draft", JSON.stringify(form.state.values))
  })
  return unsubscribe
}, [form])

Le hook form.store.subscribe se déclenche à tout changement d'état, vous permettant de vous synchroniser avec des stores externes sans déclencher de re-renders React.

Étape 10 : Tester le formulaire

Installez Vitest et React Testing Library.

npm install -D vitest @testing-library/react @testing-library/user-event jsdom @vitejs/plugin-react

Créez app/apply/page.test.tsx.

import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import ApplyPage from "./page"
 
test("affiche une erreur pour un email invalide", async () => {
  render(<ApplyPage />)
  const user = userEvent.setup()
  const emailInput = screen.getByLabelText(/email/i)
 
  await user.type(emailInput, "not-an-email")
  await user.tab()
 
  expect(await screen.findByText(/invalide/i)).toBeInTheDocument()
})
 
test("le bouton d'envoi est désactivé tant que le formulaire est invalide", () => {
  render(<ApplyPage />)
  const button = screen.getByRole("button", { name: /envoyer/i })
  expect(button).toBeDisabled()
})

Tester votre implémentation

Lancez le serveur de développement et testez chaque scénario.

  1. Commencez avec un formulaire vide et vérifiez que le bouton d'envoi est désactivé
  2. Remplissez la section personnelle avec des données invalides et confirmez les erreurs en ligne
  3. Ajoutez trois entrées d'expérience, supprimez celle du milieu, et confirmez que la première et la troisième restent intactes
  4. Collez une URL invalide dans le portfolio et observez l'erreur async après 500ms
  5. Rafraîchissez la page et vérifiez que votre progression est restaurée depuis localStorage

Dépannage

Les erreurs apparaissent avant que l'utilisateur ne tape. Vous avez probablement validatorAdapter mais avez oublié de l'envelopper avec zodValidator(). L'appel de fonction est requis.

Les types apparaissent comme any dans les renderers de champs. Assurez-vous que defaultValues est entièrement typé. Si vous utilisez un objet inline, TypeScript infère la forme exacte. Passer un objet partiel élargira les types.

La validation asynchrone se déclenche trop souvent. Augmentez onChangeAsyncDebounceMs à 800 ou utilisez plutôt onBlurAsync, qui ne s'exécute que lorsque le champ perd le focus.

Le gestionnaire de soumission n'est pas appelé. Vérifiez que vous avez empêché le comportement par défaut sur l'élément formulaire et que canSubmit est true. Exécutez console.info(form.state.errors) à l'intérieur d'un form.Subscribe pour inspecter les erreurs du formulaire entier.

Prochaines étapes

  • Ajoutez une navigation multi-étapes en utilisant form.state.fieldMeta pour valider par étape
  • Remplacez localStorage par un brouillon côté serveur avec Upstash Redis
  • Ajoutez le téléversement de fichiers avec UploadThing intégré à TanStack Form
  • Construisez un package form-kit réutilisable pour votre monorepo avec Turborepo

Conclusion

TanStack Form v1 offre ce que les autres bibliothèques de formulaires promettent mais atteignent rarement : une véritable sécurité de type de bout en bout, une flexibilité headless, et une performance qui passe à l'échelle pour des formulaires avec des centaines de champs. En le combinant avec Zod pour la validation de schéma et les server actions Next.js pour la soumission, vous disposez d'un pattern de qualité production qui fonctionne aussi bien pour un simple formulaire de contact que pour un assistant multi-étapes complexe.

Le changement mental clé est d'accepter que les formulaires sont des machines à états. Une fois que vous voyez les champs comme des abonnements sur un store normalisé, le modèle de performance, le timing de validation et le contrat d'accessibilité deviennent tous clairs. Vos utilisateurs obtiennent des formulaires plus rapides et plus fiables, et votre équipe cesse de réinventer les mêmes patterns cassés sur chaque projet.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur React Router v7 : Construire une application full-stack avec le Framework Mode.

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

Construire une Application Full-Stack avec Appwrite Cloud et Next.js 15

Apprenez à construire une application full-stack complète en utilisant Appwrite Cloud comme backend-as-a-service et Next.js 15 App Router. Ce tutoriel couvre l'authentification, les bases de données, le stockage de fichiers et les fonctionnalités temps réel.

30 min read·

Construire une application full-stack en temps réel avec Convex et Next.js 15

Apprenez à construire une application full-stack en temps réel avec Convex et Next.js 15. Ce tutoriel couvre la conception de schémas, les requêtes, les mutations, les abonnements en temps réel, l'authentification et le téléchargement de fichiers — le tout avec une sécurité de types de bout en bout.

30 min read·