Skip to Content
⭐️ Leave a star →
Form IntegrationNew

Form Integration

Wire UI’s form primitives are headless and validation-agnostic. Form.Field / Form.Control / Form.Error handle the wiring — matching labels to controls, aria-invalid / aria-describedby, showing the error slot — but they run no validation. You bring the form library; Wire UI renders the result.

That means every integration, regardless of library or framework, comes down to the same three connection points:

Value & events — the control inside Form.Control gets its value/onChange/ref from your form library.
Invalid flagForm.Field’s invalid prop is fed your library’s per-field error state, toggling aria-invalid + data-invalid.
MessageForm.Error renders your library’s error message (it’s hidden until the field is invalid).

The binding pattern

Here’s the shape with plain local state — every recipe below just swaps where value, error, and the change handler come from. Pick your framework above.

import { Form } from '@wire-ui/react' <Form.Root onSubmit={onSubmit}> <Form.Field name="email" invalid={!!error}> <Form.Label>Email</Form.Label> <Form.Control> <input type="email" value={value} onChange={(e) => setValue(e.target.value)} /> </Form.Control> {error && <Form.Error>{error}</Form.Error>} </Form.Field> </Form.Root>

Native form library

The most common setup: a framework-native form/validation library owns field state and errors.

React Hook Form — spread register() onto the control (it returns ref/name/onChange/onBlur), and read errors from formState.

import { useForm } from 'react-hook-form' import { Form } from '@wire-ui/react' type Values = { email: string } export function SignupForm() { const { register, handleSubmit, formState: { errors } } = useForm<Values>() return ( <Form.Root onSubmit={handleSubmit((data) => console.log(data))}> <Form.Field name="email" invalid={!!errors.email}> <Form.Label>Email</Form.Label> <Form.Control> <input type="email" {...register('email', { required: 'Email is required' })} /> </Form.Control> <Form.Error>{errors.email?.message}</Form.Error> </Form.Field> <button type="submit">Sign up</button> </Form.Root> ) }

TanStack Form

TanStack Form  is the one library with first-class adapters for all three frameworks — same mental model everywhere. Each field exposes state.value, a handleChange, and state.meta.errors.

import { useForm } from '@tanstack/react-form' import { Form } from '@wire-ui/react' export function SignupForm() { const form = useForm({ defaultValues: { email: '' }, onSubmit: ({ value }) => console.log(value), }) return ( <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}> <form.Field name="email" validators={{ onChange: ({ value }) => (value ? undefined : 'Email is required') }} > {(field) => ( <Form.Field name="email" invalid={field.state.meta.errors.length > 0}> <Form.Label>Email</Form.Label> <Form.Control> <input type="email" value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} /> </Form.Control> <Form.Error>{field.state.meta.errors.join(', ')}</Form.Error> </Form.Field> )} </form.Field> <button type="submit">Sign up</button> </form> ) }

Schema validation with Zod

Zod  is framework-agnostic — define the schema once and reuse it everywhere. You can validate on submit directly, or plug Zod into the libraries above (@hookform/resolvers/zod for React Hook Form, the zod validator adapters for TanStack Form, toTypedSchema for VeeValidate).

// schema.ts — shared across every framework import { z } from 'zod' export const signupSchema = z.object({ email: z.string().email('Enter a valid email'), password: z.string().min(8, 'At least 8 characters'), }) export type SignupValues = z.infer<typeof signupSchema>

Validate on submit and map error.flatten().fieldErrors onto each Form.Field:

import { useState } from 'react' import { Form } from '@wire-ui/react' import { signupSchema } from './schema' export function SignupForm() { const [errors, setErrors] = useState<Record<string, string[]>>({}) function onSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() const data = Object.fromEntries(new FormData(e.currentTarget)) const result = signupSchema.safeParse(data) if (!result.success) return setErrors(result.error.flatten().fieldErrors) setErrors({}) console.log(result.data) } return ( <Form.Root onSubmit={onSubmit}> <Form.Field name="email" invalid={!!errors.email}> <Form.Label>Email</Form.Label> <Form.Control><input name="email" type="email" /></Form.Control> <Form.Error>{errors.email?.[0]}</Form.Error> </Form.Field> </Form.Root> ) }

With React Hook Form, skip the manual mapping entirely:

import { zodResolver } from '@hookform/resolvers/zod' const { register, formState: { errors } } = useForm({ resolver: zodResolver(signupSchema) })

Server actions & form data

Wire UI forms are plain <form> elements, so they work with the uncontrolled, FormData-based server patterns each meta-framework provides. Give each control a name, let the server validate (Zod pairs well here), and feed the returned errors back into Form.Field.

Next.js (Server Actions) — the action receives FormData; surface errors with useActionState.

// actions.ts 'use server' import { signupSchema } from './schema' export async function signup(_prev: unknown, formData: FormData) { const result = signupSchema.safeParse(Object.fromEntries(formData)) if (!result.success) return { errors: result.error.flatten().fieldErrors } // …persist, redirect, etc. return { errors: {} } }
'use client' import { useActionState } from 'react' import { Form } from '@wire-ui/react' import { signup } from './actions' export function SignupForm() { const [state, action] = useActionState(signup, { errors: {} }) return ( <Form.Root action={action}> <Form.Field name="email" invalid={!!state.errors?.email}> <Form.Label>Email</Form.Label> <Form.Control><input name="email" type="email" /></Form.Control> <Form.Error>{state.errors?.email?.[0]}</Form.Error> </Form.Field> <button type="submit">Sign up</button> </Form.Root> ) }

Remix — wrap the fields in Remix’s <Form method="post"> (pass it via asChild-free composition by using Remix Form as the root element), validate in the route action, and read errors from useActionData.

Notes

Form.Control clones its single child and merges in id and ARIA attributes — so a spread like {...register('email')} or a TanStack field’s handlers coexist with Wire UI’s wiring.
Keep validation where your library wants it (on change, on blur, on submit) — Wire UI only reacts to the resulting invalid boolean, so there’s no schema lock-in.
For server patterns, prefer uncontrolled inputs with a name and let FormData carry the values — no client state needed.

See the Form component for the full list of parts and their data-* attributes.

Last updated on

MIT License © 2026 wire-ui

Form Integration – Wire UI