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:
Form.Control gets its value/onChange/ref from your form library.Form.Field’s invalid prop is fed your library’s per-field error state, toggling aria-invalid + data-invalid.Form.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.invalid boolean, so there’s no schema lock-in.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.