Skip to main content

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.

SymptomConsequence
TanStack onSubmit skippedCustom validation logic never executes.
ARIA attributes missingNo aria-invalid is set, breaking accessibility.
Error messages hiddenNo error messages render in the DOM.
Tests fail silentlyPlaywright/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 PatternWhy It Matters
Submit Invalid DataAsserts the form validation executes before the network request.
Wait for DOM TextValidates that user-facing error messages render (browser tooltips do not appear in DOM).
Assert aria-invalidConfirms 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