Forms (legacy)
How to build forms with the legacy shadcn-uikit — layout patterns, components, validation, and state management with React Hook Form and TanStack Form.
Legacy guide
This guide documents the @acronis-platform/shadcn-uikit Form / Field
layers. The next-gen @acronis-platform/ui-react library
composes forms directly from its input primitives (InputText, InputSelect,
Checkbox, Radio, Switch) without a dedicated Form / Field
abstraction.
Overview
Forms in Acronis UIKit are built from composable primitives. The library ships three layers:
- UI layer —
Input,Textarea,Select,Checkbox,Switch,RadioGroup,Combobox,DatePicker,NumberField,Label— the visual controls. - Field layer —
Field,FieldLabel,FieldDescription,FieldError,FieldGroup,FieldSet— framework-agnostic layout and accessibility primitives. No form library coupling. - Form layer —
Form,FormField,FormItem,FormLabel,FormControl,FormMessage— React Hook Form-specific wrappers that auto-wire labels, IDs, and error messages through context.
Rule: use the Field layer with TanStack Form, native forms, or any non-RHF library. Use the Form layer with React Hook Form.
Choosing a form library
| React Hook Form | TanStack Form | Native (no lib) | |
|---|---|---|---|
| Maturity | Very mature, large ecosystem | Stable v1 | N/A |
| Bundle size | ~25 kB | ~12 kB | 0 kB |
| Validation | Via resolver (Zod, Yup…) | Inline validators or adapters | required, pattern, custom |
| Async validation | Via validate + resolver | First-class, built-in | Manual |
| Server errors | Manual setError calls | form.setFieldMeta | Manual |
| Re-renders | Subscription-based, minimal | Subscription-based, minimal | — |
| Type safety | Good with Zod inference | Excellent, fully typed | — |
| When to prefer | Default choice for most apps | Large, complex forms; fine-grained field subscriptions | Simple one-off forms |
TL;DR: Use React Hook Form by default. Switch to TanStack Form when you need fine-grained re-render control, per-field async validation, or a more functional composition style.
Layout patterns
Forms are UI. Layout choices affect comprehension and completion rates as much as the controls themselves.
Single column
The default. One field per row, scanned top-to-bottom. Use for short forms (≤ 6 fields), login, checkout, or any form where the user must read each field carefully.
<form className="space-y-4 max-w-md">{/* fields stacked vertically */}</form>Two-column grid
Side-by-side pairs for logically related fields (first name / last name, country / city). Reduces vertical scroll on wider forms. Collapse to single column on mobile with sm:grid-cols-2.
View sourceHide source
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@acronis-platform/shadcn-uikit/react';
import { Input } from '@acronis-platform/shadcn-uikit/react';
import { Button } from '@acronis-platform/shadcn-uikit/react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@acronis-platform/shadcn-uikit/react';
const formSchema = z.object({
firstName: z.string().min(1, 'First name is required.'),
lastName: z.string().min(1, 'Last name is required.'),
email: z.string().email('Please enter a valid email address.'),
phone: z.string().optional(),
country: z.string().min(1, 'Please select a country.'),
city: z.string().optional(),
});
export function FormLayoutTwoColumn() {
const form = useForm<z.infer<typeof formSchema>>({
// TO-DO.md #3: apps/demos zod 3 schemas vs zod 4 types in @hookform/resolvers — cast bypasses the false-positive
resolver: zodResolver(formSchema as never),
defaultValues: {
firstName: '',
lastName: '',
email: '',
phone: '',
country: '',
city: '',
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
alert(JSON.stringify(values, null, 2));
}
return (
<div className="w-full max-w-2xl">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Two-column row */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First name</FormLabel>
<FormControl>
<Input placeholder="Jane" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Two-column row */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="jane@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>
Phone{' '}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</FormLabel>
<FormControl>
<Input
type="tel"
placeholder="+1 555 000 0000"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Two-column row */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select country" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="gb">United Kingdom</SelectItem>
<SelectItem value="de">Germany</SelectItem>
<SelectItem value="fr">France</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>
City{' '}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</FormLabel>
<FormControl>
<Input placeholder="San Francisco" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit">Save</Button>
</form>
</Form>
</div>
);
}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField name="firstName" ... />
<FormField name="lastName" ... />
</div>Sectioned form
Long forms (settings pages, account setup, multi-topic data entry) benefit from visual sections with headings and <Separator> dividers. Each section has a title, optional description, and a coherent group of fields.
View sourceHide source
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@acronis-platform/shadcn-uikit/react';
import { Input } from '@acronis-platform/shadcn-uikit/react';
import { Textarea } from '@acronis-platform/shadcn-uikit/react';
import { Switch } from '@acronis-platform/shadcn-uikit/react';
import { Button } from '@acronis-platform/shadcn-uikit/react';
import { Separator } from '@acronis-platform/shadcn-uikit/react';
const formSchema = z.object({
displayName: z.string().min(2, 'Display name must be at least 2 characters.'),
email: z.string().email('Please enter a valid email address.'),
bio: z.string().max(160, 'Bio must not exceed 160 characters.').optional(),
emailNotifications: z.boolean(),
marketingEmails: z.boolean(),
currentPassword: z.string().optional(),
newPassword: z
.string()
.min(8, 'Password must be at least 8 characters.')
.optional(),
});
export function FormLayoutSections() {
const form = useForm<z.infer<typeof formSchema>>({
// TO-DO.md #3: apps/demos zod 3 schemas vs zod 4 types in @hookform/resolvers — cast bypasses the false-positive
resolver: zodResolver(formSchema as never),
defaultValues: {
displayName: '',
email: '',
bio: '',
emailNotifications: true,
marketingEmails: false,
currentPassword: '',
newPassword: '',
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
alert(JSON.stringify(values, null, 2));
}
return (
<div className="w-full max-w-lg">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Section: Profile */}
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold">Profile</h3>
<p className="text-sm text-muted-foreground">
Update your public profile information.
</p>
</div>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="you@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="A short bio about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>Up to 160 characters.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Section: Notifications */}
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold">Notifications</h3>
<p className="text-sm text-muted-foreground">
Choose how you want to be notified.
</p>
</div>
<FormField
control={form.control}
name="emailNotifications"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-sm font-medium">
Email notifications
</FormLabel>
<FormDescription>
Receive notifications about account activity.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketingEmails"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-sm font-medium">
Marketing emails
</FormLabel>
<FormDescription>
Get updates on new features and promotions.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<Separator />
{/* Section: Security */}
<div className="space-y-4">
<div>
<h3 className="text-base font-semibold">Security</h3>
<p className="text-sm text-muted-foreground">
Change your password. Leave blank to keep the current one.
</p>
</div>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>
Must be at least 8 characters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit">Save changes</Button>
</form>
</Form>
</div>
);
}
<div className="space-y-8">
<section className="space-y-4">
<div>
<h3 className="text-base font-semibold">Profile</h3>
<p className="text-sm text-muted-foreground">
Update your public profile.
</p>
</div>
{/* fields */}
</section>
<Separator />
<section className="space-y-4">
<h3 className="text-base font-semibold">Notifications</h3>
{/* fields */}
</section>
</div>Inline labels (horizontal)
Label and control on the same row. Works well for key–value edit UIs or compact admin tables. Use flex items-center justify-between and constrain the control width.
<div className="flex items-center justify-between">
<Label htmlFor="emails">Email notifications</Label>
<Switch id="emails" />
</div>Component reference
Every form control in UIKit has a consistent anatomy. The table below shows how to wire each one inside FormItem.
| Component | Import | FormControl wraps | Notes |
|---|---|---|---|
Input | @acronis-platform/shadcn-uikit/react | <Input {...field} /> | Spread field directly |
Textarea | same | <Textarea {...field} /> | Add className="resize-none" when height is fixed |
Select | same | <SelectTrigger> | Wrap outer <Select> with onValueChange={field.onChange} |
Checkbox | same | <Checkbox checked={field.value} onCheckedChange={field.onChange} /> | Use checked not value |
Switch | same | <Switch checked={field.value} onCheckedChange={field.onChange} /> | Same as Checkbox |
RadioGroup | same | <RadioGroup onValueChange={field.onChange} defaultValue={field.value}> | Wrap RadioGroupItems inside |
Combobox | same | <Combobox value={field.value} onValueChange={field.onChange} /> | — |
DatePicker | same | <DatePicker value={field.value} onValueChange={field.onChange} /> | Value is Date | undefined |
NumberField | same | <NumberField value={field.value} onChange={field.onChange} /> | Value is number |
FormItem anatomy
<FormItem>
<FormLabel>Label text</FormLabel> {/* accessible <label> */}
<FormControl>
<Input ... /> {/* the actual control */}
</FormControl>
<FormDescription>Helper text.</FormDescription> {/* optional */}
<FormMessage /> {/* validation error */}
</FormItem>FormLabel automatically associates with the control via React context — no manual htmlFor needed when using the React Hook Form Form/FormField wrappers.
FormMessage renders the first validation error for the field. It reads from form context and shows nothing when the field is valid.
When using TanStack Form or any non-RHF library, use Field / FieldLabel / FieldError instead — see the Field component docs and the TanStack Form section below.
React Hook Form + Zod
The Form component in UIKit is a thin wrapper around React Hook Form's FormProvider. Use it as the default approach.
Basic single field
View sourceHide source
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@acronis-platform/shadcn-uikit/react';
import { Input } from '@acronis-platform/shadcn-uikit/react';
import { Button } from '@acronis-platform/shadcn-uikit/react';
const formSchema = z.object({
username: z.string().min(2, {
message: 'Username must be at least 2 characters.',
}),
});
export function FormBasic() {
const form = useForm<z.infer<typeof formSchema>>({
// Cast is required because apps/demos uses zod v3 schemas while @hookform/resolvers is typed against zod v4.
resolver: zodResolver(formSchema as never),
defaultValues: {
username: '',
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values);
alert(JSON.stringify(values, null, 2));
}
return (
<div className="w-full max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter username" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
</div>
);
}
Checkboxes, selects, and radio groups
View sourceHide source
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@acronis-platform/shadcn-uikit/react';
import { Button } from '@acronis-platform/shadcn-uikit/react';
import { Checkbox } from '@acronis-platform/shadcn-uikit/react';
import {
RadioGroup,
RadioGroupItem,
} from '@acronis-platform/shadcn-uikit/react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@acronis-platform/shadcn-uikit/react';
const formSchema = z.object({
notifications: z.boolean(),
marketing: z.boolean(),
security: z.boolean(),
language: z.string({
message: 'Please select a language.',
}),
theme: z.enum(['light', 'dark', 'system'], {
message: 'Please select a theme.',
}),
});
export function FormSettings() {
const form = useForm<z.infer<typeof formSchema>>({
// TO-DO.md #3: apps/demos zod 3 schemas vs zod 4 types in @hookform/resolvers — cast bypasses the false-positive
resolver: zodResolver(formSchema as never),
defaultValues: {
notifications: true,
marketing: false,
security: true,
theme: 'system',
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values);
alert(JSON.stringify(values, null, 2));
}
return (
<div className="w-full max-w-md rounded-lg border p-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-3">Email Notifications</h4>
<FormField
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
Receive email notifications
</FormLabel>
<FormDescription>
Get notified about important updates
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="marketing"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
Marketing emails
</FormLabel>
<FormDescription>
Receive emails about new products and features
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="security"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
Security alerts
</FormLabel>
<FormDescription>
Get notified about security updates
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
<SelectItem value="fr">French</SelectItem>
<SelectItem value="de">German</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Theme</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="light" />
</FormControl>
<FormLabel className="font-normal">Light</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="dark" />
</FormControl>
<FormLabel className="font-normal">Dark</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="system" />
</FormControl>
<FormLabel className="font-normal">System</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Save settings
</Button>
</form>
</Form>
</div>
);
}
Setup pattern
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from '@acronis-platform/shadcn-uikit/react';
// 1. Define schema
const schema = z.object({
email: z.string().email('Invalid email address.'),
password: z.string().min(8, 'At least 8 characters.'),
});
// 2. Infer types
type FormValues = z.infer<typeof schema>;
export function LoginForm() {
// 3. Initialise hook
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
});
// 4. Handle submit
function onSubmit(values: FormValues) {
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* 5. Connect each field */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Log in</Button>
</form>
</Form>
);
}Server-side errors
async function onSubmit(values: FormValues) {
const result = await loginUser(values);
if (result.error) {
form.setError('email', { message: result.error });
return;
}
}Submit state
const { isSubmitting, isSubmitSuccessful } = form.formState
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Save'}
</Button>TanStack Form + Zod
Use TanStack Form when you need fine-grained control over which fields cause re-renders, or when you prefer the render-prop / subscription model.
Install the packages:
pnpm add @tanstack/react-form @tanstack/zod-form-adapterTanStack Form has no React Hook Form context, so use the Field layer (Field, FieldLabel, FieldDescription, FieldError) instead of the FormItem/FormLabel RHF primitives.
View sourceHide source
import * as React from 'react';
import { useForm } from '@tanstack/react-form';
import * as z from 'zod';
import {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
} from '@acronis-platform/shadcn-uikit/react';
import { Input } from '@acronis-platform/shadcn-uikit/react';
import { Textarea } from '@acronis-platform/shadcn-uikit/react';
import { Button } from '@acronis-platform/shadcn-uikit/react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@acronis-platform/shadcn-uikit/react';
const schema = z.object({
username: z
.string()
.min(2, 'At least 2 characters.')
.max(30, 'Max 30 characters.'),
email: z.string().email('Enter a valid email address.'),
role: z.string().min(1, 'Please select a role.'),
bio: z.string().max(160, 'Max 160 characters.').optional(),
});
type FormValues = z.infer<typeof schema>;
export function FieldTanstackForm() {
const [submitted, setSubmitted] = React.useState<FormValues | null>(null);
const form = useForm({
defaultValues: { username: '', email: '', role: '', bio: '' },
onSubmit: async ({ value }) => setSubmitted(value as FormValues),
});
if (submitted) {
return (
<div className="w-full max-w-md rounded-lg border p-6 space-y-3">
<p className="text-sm font-medium text-green-600">Saved!</p>
<pre className="text-xs bg-muted rounded p-3 overflow-auto">
{JSON.stringify(submitted, null, 2)}
</pre>
<Button variant="outline" size="sm" onClick={() => setSubmitted(null)}>
Edit again
</Button>
</div>
);
}
return (
<div className="w-full max-w-md">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="username"
validators={{
onChange: ({ value }) => {
const result = schema.shape.username.safeParse(value);
return result.success
? undefined
: result.error.issues[0]?.message;
},
}}
>
{(field) => (
<Field
data-invalid={
field.state.meta.isTouched &&
field.state.meta.errors.length > 0
? 'true'
: undefined
}
>
<FieldLabel htmlFor={field.name}>Username</FieldLabel>
<Input
id={field.name}
placeholder="johndoe"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={
field.state.meta.isTouched &&
field.state.meta.errors.length > 0
}
/>
<FieldDescription>Your public display name.</FieldDescription>
{field.state.meta.isTouched && (
<FieldError
errors={field.state.meta.errors.map((e) => ({
message: e?.toString(),
}))}
/>
)}
</Field>
)}
</form.Field>
<form.Field
name="email"
validators={{
onChange: ({ value }) => {
const result = schema.shape.email.safeParse(value);
return result.success
? undefined
: result.error.issues[0]?.message;
},
}}
>
{(field) => (
<Field
data-invalid={
field.state.meta.isTouched &&
field.state.meta.errors.length > 0
? 'true'
: undefined
}
>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
id={field.name}
type="email"
placeholder="jane@example.com"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={
field.state.meta.isTouched &&
field.state.meta.errors.length > 0
}
/>
{field.state.meta.isTouched && (
<FieldError
errors={field.state.meta.errors.map((e) => ({
message: e?.toString(),
}))}
/>
)}
</Field>
)}
</form.Field>
<form.Field
name="role"
validators={{
onChange: ({ value }) => {
const result = schema.shape.role.safeParse(value);
return result.success
? undefined
: result.error.issues[0]?.message;
},
}}
>
{(field) => (
<Field
data-invalid={
field.state.meta.isTouched &&
field.state.meta.errors.length > 0
? 'true'
: undefined
}
>
<FieldLabel htmlFor={field.name}>Role</FieldLabel>
<Select
value={field.state.value}
onValueChange={(val) => field.handleChange(val ?? '')}
>
<SelectTrigger id={field.name} onBlur={field.handleBlur}>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
{field.state.meta.isTouched && (
<FieldError
errors={field.state.meta.errors.map((e) => ({
message: e?.toString(),
}))}
/>
)}
</Field>
)}
</form.Field>
<form.Field
name="bio"
validators={{
onChange: ({ value }) => {
const result = schema.shape.bio.safeParse(value);
return result.success
? undefined
: result.error.issues[0]?.message;
},
}}
>
{(field) => (
<Field>
<FieldLabel htmlFor={field.name}>Bio</FieldLabel>
<Textarea
id={field.name}
placeholder="Tell us a little about yourself"
className="resize-none"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
<FieldDescription>Up to 160 characters.</FieldDescription>
{field.state.meta.isTouched && (
<FieldError
errors={field.state.meta.errors.map((e) => ({
message: e?.toString(),
}))}
/>
)}
</Field>
)}
</form.Field>
<form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Saving…' : 'Save profile'}
</Button>
)}
</form.Subscribe>
</FieldGroup>
</form>
</div>
);
}
Setup pattern
import { useForm } from '@tanstack/react-form';
import { zodValidator } from '@tanstack/zod-form-adapter';
import * as z from 'zod';
import {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
} from '@acronis-platform/shadcn-uikit/react';
const schema = z.object({
email: z.string().email('Invalid email address.'),
});
export function TanstackLoginForm() {
const form = useForm({
defaultValues: { email: '' },
validatorAdapter: zodValidator(),
validators: { onChange: schema },
onSubmit: async ({ value }) => console.log(value),
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="email" validators={{ onChange: schema.shape.email }}>
{(field) => (
<Field
data-invalid={
field.state.meta.isTouched && field.state.meta.errors.length > 0
? 'true'
: undefined
}
>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
id={field.name}
type="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
aria-invalid={
field.state.meta.isTouched &&
field.state.meta.errors.length > 0
}
/>
{field.state.meta.isTouched && (
<FieldError
errors={field.state.meta.errors.map((e) => ({
message: e?.toString(),
}))}
/>
)}
</Field>
)}
</form.Field>
<form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Logging in…' : 'Log in'}
</Button>
)}
</form.Subscribe>
</FieldGroup>
</form>
);
}Key differences from React Hook Form
- Use
FieldnotFormItem—FormItem/FormLabel/FormMessageread from RHF'suseFormContextand will crash outside a<Form>provider. UseField,FieldLabel, andFieldErrorinstead. - Per-field subscriptions —
form.Subscribere-renders only the section that reads the subscribed state slice, not the whole form. htmlForis manual — passid={field.name}on the control andhtmlFor={field.name}onFieldLabel.- Error display is explicit — map
field.state.meta.errorsintoFieldError'serrorsprop. - Touched state — use
field.state.meta.isTouchedto defer error display until after the user has left the field.
Error handling
Client errors
Validation errors surface automatically via <FormMessage /> (RHF) or field.state.meta.errors (TanStack Form). They appear below the control after submission or (for TanStack) when the field loses focus.
Server errors (React Hook Form)
form.setError('root.serverError', {
message: 'Something went wrong. Please try again.',
});
// or target a specific field:
form.setError('email', { message: 'Email already in use.' });Display a root error:
{
form.formState.errors.root?.serverError && (
<p className="text-sm text-destructive">
{form.formState.errors.root.serverError.message}
</p>
);
}Validation timing
| Strategy | RHF mode | TanStack validators key |
|---|---|---|
| On submit only | mode: 'onSubmit' (default) | onSubmit |
| On blur | mode: 'onBlur' | onBlur |
| On change (after first submit) | mode: 'onTouched' | onChange + check isTouched |
| On every keystroke | mode: 'onChange' | onChange |
Prefer onSubmit or onTouched for most forms — validating on every keystroke disrupts the user before they finish typing.
Accessibility
All form controls in UIKit are built on Base UI primitives that follow the WAI-ARIA patterns.
- Labels are required. Never render a control without an associated label.
FormLabelconnects automatically via context in RHF mode. In TanStack mode, useid+htmlFormanually. - Error messages must be announced.
FormMessagerenders withrole="alert"so screen readers announce errors on change. - Groups need a legend. When grouping related controls (e.g. a radio group or checkbox group), wrap them in a
<fieldset>with a<legend>. - Disabled vs read-only. Disabled controls are excluded from tab order and form submission. Use
readOnlywhen the value should be submitted but not editable. - Required fields. Pass
requiredto the control and mark the label visually (*or "(required)").
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email{' '}
<span aria-hidden="true" className="text-destructive">
*
</span>
</FormLabel>
<FormControl>
<Input type="email" required aria-required="true" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>