Tanstack Forms
Forms are where users meet friction. Every field is a chance to lose them.
The right form library removes boilerplate without hiding control. The wrong one fights your validation logic at scale.
Comparison
| Criteria | TanStack Form | React Hook Form | Native HTML |
|---|---|---|---|
| Bundle size | ~15 KB | ~9 KB | 0 KB |
| Validation | Built-in + Zod/Valibot | Resolver pattern (Zod) | Manual |
| Render control | Fine-grained subscription | Uncontrolled by default | Full control |
| Server Components | Compatible | Compatible | Native |
| TypeScript | First-class | First-class | Manual types |
| Learning curve | Moderate | Low | None |
| Multi-step forms | Built-in | Manual orchestration | Manual |
| Best for | Complex forms, wizards | Simple-to-medium forms | One-off forms |
Key Patterns
| Pattern | When | Why |
|---|---|---|
| Schema validation | Always | Single source of truth for client and server |
| Progressive disclosure | Multi-step flows | Reduce cognitive load per screen |
| Optimistic submission | Low-risk actions | Faster perceived performance |
| Server-side validation | Always | Client validation is a courtesy, not security |
| Field-level errors | User-facing forms | Immediate feedback beats form-level error dumps |
Progressive Enhancement
The action attribute on <form> accepts a server action directly. The form submits to the server whether JavaScript has loaded or not. A client function (() => form.handleSubmit()) requires hydration first — the form is dead until then.
| Tier | Pattern | Works Before JS | Bundle Cost |
|---|---|---|---|
| 1. Native | <form action={serverAction}> + name attrs | Yes | 0 KB |
| 2. Enhanced | TanStack validates client-side, server action stays in action | Yes (degrades to tier 1) | ~15 KB |
| 3. Optimistic | useActionState for pending UI | Yes (degrades to tier 1) | ~15 KB + React |
// Dead before hydration
<form action={() => form.handleSubmit()}>
// Works immediately
<form action={createEntityAction}>
Three requirements for progressive forms:
| Requirement | What | Why |
|---|---|---|
action={serverAction} | Server action as form action | Works before hydration |
name on every input | Server reads FormData | No client state required |
redirect() in server action | Navigation after success | No client router required |
Generator Enforcement
The form layer has been the consistent source of bugs. Discipline fails under pressure. Generators don't. An Nx generator scaffolds every new form with the correct pattern baked in — server action in action, name attributes on inputs, FormShell wrapping, Zod schema at the boundary.
| What Generator Produces | Why |
|---|---|
| Server action file with 4-step pattern | Hex boundary enforced from first line |
Zod schema for FormData parsing | Validation at boundary, not in UI |
FormShell with action={serverAction} | Progressive enhancement by default |
name attribute on every field | Server reads FormData without client state |
| Integration test stub (server action) | L2 test proves the action works without UI |
| E2E test stub (form submission) | L3 test proves the form submits end-to-end |
Companion lint rules:
| Rule | Detects | Severity |
|---|---|---|
no-client-only-form-action | action={() => on <form> or <FormShell> | Error |
require-name-attribute | <FormInput> or <Field> without name | Error |
require-server-action-import | Form file without a 'use server' action import | Warning |
Run nx generate @stackmates/generators:form --name=contact --entity=contacts. The output compiles, the server action validates, the form submits before hydration. No manual wiring required.
This documents the WHAT/WHY spec for the generator. The engineering repo builds and maintains the actual generator. This page is the requirement for the generator's behavior.
Decision Rule
Start with native HTML forms and server actions. The action attribute takes a server action, not a client function. Add TanStack Form when you hit conditional fields, multi-step wizards, complex validation chains, or array fields. Even then, the server action stays in action. TanStack enhances — it does not replace.
Context
- State Management — Forms are state; pick the right boundary
- App Router — Server actions change form architecture
- Components — Form fields are components first
- Anti-patterns — Over-engineering forms is the top one
Questions
How does the action attribute determine whether a form works before hydration?
- When does client-side validation justify its bundle cost over server-only validation?
- What breaks when TanStack Form's
handleSubmitreplaces the server action inaction? - How do multi-step forms interact with URL state and browser back?