Acronis UIKit
Legacy (shadcn-uikit)

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 layerInput, Textarea, Select, Checkbox, Switch, RadioGroup, Combobox, DatePicker, NumberField, Label — the visual controls.
  • Field layerField, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldSet — framework-agnostic layout and accessibility primitives. No form library coupling.
  • Form layerForm, 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 FormTanStack FormNative (no lib)
MaturityVery mature, large ecosystemStable v1N/A
Bundle size~25 kB~12 kB0 kB
ValidationVia resolver (Zod, Yup…)Inline validators or adaptersrequired, pattern, custom
Async validationVia validate + resolverFirst-class, built-inManual
Server errorsManual setError callsform.setFieldMetaManual
Re-rendersSubscription-based, minimalSubscription-based, minimal
Type safetyGood with Zod inferenceExcellent, fully typed
When to preferDefault choice for most appsLarge, complex forms; fine-grained field subscriptionsSimple 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 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.

Profile

Update your public profile information.

Up to 160 characters.

Notifications

Choose how you want to be notified.

Receive notifications about account activity.

Get updates on new features and promotions.

Security

Change your password. Leave blank to keep the current one.

Must be at least 8 characters.

View 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.

ComponentImportFormControl wrapsNotes
Input@acronis-platform/shadcn-uikit/react<Input {...field} />Spread field directly
Textareasame<Textarea {...field} />Add className="resize-none" when height is fixed
Selectsame<SelectTrigger>Wrap outer <Select> with onValueChange={field.onChange}
Checkboxsame<Checkbox checked={field.value} onCheckedChange={field.onChange} />Use checked not value
Switchsame<Switch checked={field.value} onCheckedChange={field.onChange} />Same as Checkbox
RadioGroupsame<RadioGroup onValueChange={field.onChange} defaultValue={field.value}>Wrap RadioGroupItems inside
Comboboxsame<Combobox value={field.value} onValueChange={field.onChange} />
DatePickersame<DatePicker value={field.value} onValueChange={field.onChange} />Value is Date | undefined
NumberFieldsame<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

This is your public display name.

View 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

Email Notifications

Get notified about important updates

Receive emails about new products and features

Get notified about security updates

View 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-adapter

TanStack Form has no React Hook Form context, so use the Field layer (Field, FieldLabel, FieldDescription, FieldError) instead of the FormItem/FormLabel RHF primitives.

Your public display name.

Up to 160 characters.

View 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 Field not FormItemFormItem/FormLabel/FormMessage read from RHF's useFormContext and will crash outside a <Form> provider. Use Field, FieldLabel, and FieldError instead.
  • Per-field subscriptionsform.Subscribe re-renders only the section that reads the subscribed state slice, not the whole form.
  • htmlFor is manual — pass id={field.name} on the control and htmlFor={field.name} on FieldLabel.
  • Error display is explicit — map field.state.meta.errors into FieldError's errors prop.
  • Touched state — use field.state.meta.isTouched to 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

StrategyRHF modeTanStack validators key
On submit onlymode: 'onSubmit' (default)onSubmit
On blurmode: 'onBlur'onBlur
On change (after first submit)mode: 'onTouched'onChange + check isTouched
On every keystrokemode: '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. FormLabel connects automatically via context in RHF mode. In TanStack mode, use id + htmlFor manually.
  • Error messages must be announced. FormMessage renders with role="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 readOnly when the value should be submitted but not editable.
  • Required fields. Pass required to 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>
  )}
/>
Edit on GitHub

On this page