Next.js Forms
Why abstract forms? Because browser defaults sabotage validation.
// ❌ The Footgun (Browser tooltip fires, TanStack validation silently fails)
<form action={() => form.handleSubmit()}>
// ✅ The Pit of Success
<FormShell form={form}>
Engineer discipline into the system; don't rely on operator memory. The correct path must be the default path.
The Core Problem
Browser native validation (HMTL5) silently blocks TanStack Form validators from executing. For example, an <input type="email"> without noValidate on the wrapping <form> means the browser native tooltip fires first.
| Symptom | Consequence |
|---|---|
TanStack onSubmit skipped | Custom validation logic never executes. |
| ARIA attributes missing | No aria-invalid is set, breaking accessibility. |
| Error messages hidden | No error messages render in the DOM. |
| Tests fail silently | Playwright/E2E tests see a hanging form submit, as the native tooltip is invisible to them. |
The FormShell Organism
The solution is the FormShell organism. It wraps the native <form> element, taking over submission and automatically suppressing browser validation.
import { FormApi } from "@tanstack/react-form";
interface FormShellProps {
form: FormApi<any, any>;
children: React.ReactNode;
className?: string;
}
export function FormShell({ form, children, className, ...props }: FormShellProps) {
return (
<form action={() => form.handleSubmit()} noValidate className={className} {...props}>
{children}
</form>
);
}
Usage Pattern
Developers pass the TanStack form instance directly to the shell and get the correct behavior by default.
import { FormShell } from "@stackmates/ui-forms";
export function ContactForm() {
const form = useForm({
/* ... */
});
return (
<FormShell form={form}>
<Field name="email">{/* ... */}</Field>
</FormShell>
);
}
E2E Testing Strategy
Because FormShell guarantees that TanStack Form handles validation, it establishes a predictable pattern for Playwright to interact with error states.
| Testing Pattern | Why It Matters |
|---|---|
| Submit Invalid Data | Asserts the form validation executes before the network request. |
| Wait for DOM Text | Validates that user-facing error messages render (browser tooltips do not appear in DOM). |
Assert aria-invalid | Confirms the accessibility layer is providing feedback. |
// Playwright Validation Testing Pattern
test("validates email format", async ({ page }) => {
await page.getByLabel("Email").fill("invalid-email");
await page.getByRole("button", { name: "Submit" }).click();
// Assert DOM-visible error
await expect(page.getByText("Invalid email format")).toBeVisible();
// Assert accessibility state
await expect(page.getByLabel("Email")).toHaveAttribute("aria-invalid", "true");
});
Context
- Anti-Patterns — Prevent architecture violations like raw forms
- Component Driven Development — Form components structure
- Best Practices — Ensure forms respect Layer boundaries and types
- Testing — E2E validation against forms
- React Components — Composition of generic UI logic