Form
Forms with react-hook-form: FormField wires label, error and value into any library component automatically.
'use client';
import { zUz } from '@akhatmirzo/core/zod';
import {
Checkbox,
DatePicker,
Input,
PhoneInput,
PinflInput,
Radio,
RadioGroup,
RegionSelect,
} from '@akhatmirzo/ui';
import { Form, FormField, FormRootError, FormSubmit } from '@akhatmirzo/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const schema = z.object({
fullName: z.string().trim().min(3, "To'liq ismingizni kiriting"),
phone: zUz.phone(),
pinfl: zUz.pinfl(),
birthDate: z.date({ error: "Tug'ilgan sanani kiriting" }),
gender: z.enum(['male', 'female'], { error: 'Jinsni tanlang' }),
region: zUz.region(),
password: zUz.password({ minScore: 2 }),
terms: z.literal(true, { error: 'Shartlarga rozilik bering' }),
});
type Registration = z.output<typeof schema>;
export default function FormRegistration() {
const [saved, setSaved] = useState<Registration | null>(null);
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { fullName: '', phone: '', pinfl: '', region: '', password: '' },
});
const save = async (data: Registration) => {
await new Promise((r) => setTimeout(r, 600));
if (data.pinfl.startsWith('6')) {
form.setError('root', {
message: "Bu JShShIR bilan foydalanuvchi allaqachon ro'yxatdan o'tgan",
});
return;
}
setSaved(data);
};
return (
<div className="flex w-full max-w-md flex-col gap-4">
<Form form={form} onSubmit={save} className="flex flex-col gap-4">
<FormField name="fullName" label="To'liq ism" required>
<Input placeholder="Sarvar Abduxoliqov" autoComplete="name" />
</FormField>
<FormField name="phone" label="Telefon" description="SMS kod yuboriladi" required>
<PhoneInput />
</FormField>
<FormField name="pinfl" label="JShShIR" description="Pasportdagi 14 xonali raqam" required>
<PinflInput />
</FormField>
<FormField name="birthDate" label="Tug'ilgan sana" required>
<DatePicker />
</FormField>
<FormField name="gender" label="Jinsi" required>
<RadioGroup orientation="horizontal">
<Radio value="male" label="Erkak" />
<Radio value="female" label="Ayol" />
</RadioGroup>
</FormField>
<FormField name="region" label="Hudud" required>
<RegionSelect />
</FormField>
<FormField name="password" label="Parol" required>
<Input type="password" autoComplete="new-password" />
</FormField>
<FormField name="terms">
<Checkbox label="Foydalanish shartlariga roziman" />
</FormField>
<FormRootError />
<FormSubmit fullWidth>Ro'yxatdan o'tish</FormSubmit>
</Form>
{saved && (
<pre className="overflow-x-auto rounded-lg bg-surface-muted p-3 text-xs text-text-muted">
{JSON.stringify(saved, null, 2)}
</pre>
)}
</div>
);
}
Installation
@akhatmirzo/ui/form is a separate entry point. The main @akhatmirzo/ui import never pulls
in react-hook-form, so projects without forms ship no extra code. react-hook-form is an
optional peer dependency that you install yourself (zod and the resolver are recommended for
validation):
pnpm add react-hook-form @hookform/resolvers zodImport
import { Form, FormField, FormSubmit, FormRootError, useFormField } from '@akhatmirzo/ui/form';When to use
- Any form with validation — sign-up, profile, checkout. No hand-written labels, error
text,
aria-*or focus handling. - For Uzbek fields pair it with the
zUzschemas: phone, PINFL, passport, money — ready-made checks with Uzbek error messages. - For a tiny one- or two-field form you don't need
react-hook-form—FieldplususeStateis enough. - Put one control inside each
FormField. Need several elements? Userenderor separateFormFields.
Full example
zodResolver validates the schema and the zUz transforms hand clean values to
onSubmit: phone becomes "998901234567", money becomes 1250000 instead of "1 250 000".
The value in the form (what the user sees) stays as typed.
'use client';
import { zUz } from '@akhatmirzo/core/zod';
import { Checkbox, Input, PhoneInput, PinflInput, RegionSelect } from '@akhatmirzo/ui';
import { Form, FormField, FormRootError, FormSubmit } from '@akhatmirzo/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const schema = z.object({
fullName: z.string().trim().min(3, 'Enter your full name'),
phone: zUz.phone(),
pinfl: zUz.pinfl(),
region: zUz.region(),
terms: z.literal(true, { error: 'Please accept the terms' }),
});
export function RegisterForm() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { fullName: '', phone: '', pinfl: '', region: '' },
});
async function save(data: z.output<typeof schema>) {
// data.phone === '998901234567'
const res = await fetch('/api/register', { method: 'POST', body: JSON.stringify(data) });
if (!res.ok) form.setError('root', { message: 'The server did not respond, try again' });
}
return (
<Form form={form} onSubmit={save} className="flex flex-col gap-4">
<FormField name="fullName" label="Full name" required>
<Input />
</FormField>
<FormField name="phone" label="Phone" description="We will send an SMS code">
<PhoneInput />
</FormField>
<FormField name="pinfl" label="PINFL">
<PinflInput />
</FormField>
<FormField name="region" label="Region">
<RegionSelect />
</FormField>
<FormField name="terms">
<Checkbox label="I accept the terms of use" />
</FormField>
<FormRootError />
<FormSubmit>Sign up</FormSubmit>
</Form>
);
}Form renders <form noValidate> plus FormProvider; onSubmit runs only after validation
passes. FormSubmit switches to loading while submitting, and FormRootError shows
setError('root', ...) (or 'root.server') in an Alert.
Compatibility table
FormField looks at the child type and passes the matching props. Library components have a
built-in table; label, description, id, aria-describedby, aria-invalid, required
and disabled reach all of them through the Field context.
| Component | Props passed | Form value |
|---|---|---|
Input, Textarea | value, onChange(event), name | string |
native <input>, <textarea>, <select> | value, onChange(event), name, id, aria-*, disabled | string |
native <input type="checkbox"> | checked, onChange(event), name | boolean |
Checkbox, Switch | checked, onCheckedChange | boolean |
RadioGroup, Select, RegionSelect | value, onValueChange, name | string (UzRegionId) |
SegmentedControl, ColorPicker, OtpInput | value, onValueChange | string |
MaskedInput, PhoneInput, CardInput | value, onValueChange | string (digits only) |
PinflInput, PassportInput, InnInput, BankAccountInput, CarPlateInput | value, onValueChange | string (no spaces) |
MoneyInput | value, onValueChange | number | null |
Slider | value, onValueChange | number[] |
Rating | value, onValueChange | number |
Combobox | value, onValueChange | string | null · multiple — string[] |
TagInput | value, onValueChange | string[] |
DatePicker | value, onValueChange | Date | null |
DateRangePicker | value, onValueChange | { from, to } | null |
FileUpload | onFilesChange (uncontrolled) | FileUploadItem[] |
| any other component | value, onValueChange (convention) | — |
Rules:
- Empty values. When the form value is
undefined/null, the component still gets an empty value so it stays controlled:''for text,falseforCheckbox,[]forTagInput,0forRating,[min]forSlider. That's whyreset()clears every component. - Your own handlers still run.
<Input onChange={log} />— yours first, then the form's. FileUploadowns its internal list: when the form value changes from outside (reset,setValue) the component is recreated and the list is cleared.- Focus. A submit error or
form.setFocus(name)focuses the field's main element (input, Select trigger, slider thumb …). You don't need to pass aref.
Every row is covered by integration tests: the value flows in and out, the error shows in
Field, focus moves to the invalid field and reset() works.
Mixed components
value / onValueChange components (SegmentedControl, MoneyInput, TagInput) and the
uncontrolled FileUpload in one form. The price is validated with zUz.money({ min, max }).
'use client';
import { zUz } from '@akhatmirzo/core/zod';
import { FileUpload, MoneyInput, SegmentedControl, TagInput, toast } from '@akhatmirzo/ui';
import { Form, FormField, FormSubmit } from '@akhatmirzo/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const schema = z.object({
condition: z.enum(['new', 'used'], { error: 'Holatini tanlang' }),
price: zUz.money({ min: 10_000, max: 500_000_000 }),
tags: z.array(z.string()).min(1, 'Kamida bitta teg').max(5, "Ko'pi bilan 5 ta teg"),
photos: z.array(z.unknown()).min(1, 'Kamida bitta rasm yuklang'),
});
export default function FormListing() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { price: null, tags: [], photos: [] },
});
return (
<Form
form={form}
onSubmit={(data) => {
toast.success("E'lon saqlandi", {
description: `${data.price} so'm · ${data.tags.join(', ')}`,
});
form.reset();
}}
className="flex w-full max-w-md flex-col gap-4"
>
<FormField name="condition" label="Holati">
<SegmentedControl fullWidth>
<SegmentedControl.Item value="new">Yangi</SegmentedControl.Item>
<SegmentedControl.Item value="used">Ishlatilgan</SegmentedControl.Item>
</SegmentedControl>
</FormField>
<FormField name="price" label="Narxi" description="10 000 so'mdan 500 mln so'mgacha">
<MoneyInput showWords />
</FormField>
<FormField name="tags" label="Teglar" description="Enter yoki vergul bilan qo'shing">
<TagInput placeholder="telefon, samsung" />
</FormField>
<FormField name="photos" label="Rasmlar">
<FileUpload accept={['image/*']} multiple maxFiles={4} />
</FormField>
<FormSubmit>E'lonni joylash</FormSubmit>
</Form>
);
}
Fallback: render
For an element that isn't in the table or has a different API, use render — you get
value, onValueChange and controlProps (id, aria-*):
'use client';
import { cn } from '@akhatmirzo/ui';
import { Form, FormField, FormSubmit } from '@akhatmirzo/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const slots = ['09:00–12:00', '12:00–15:00', '15:00–18:00', '18:00–21:00'];
const schema = z.object({
slot: z.string().min(1, 'Yetkazib berish vaqtini tanlang'),
});
export default function FormRenderFallback() {
const form = useForm({ resolver: zodResolver(schema), defaultValues: { slot: '' } });
return (
<Form
form={form}
onSubmit={(data) => alert(`Tanlandi: ${data.slot}`)}
className="flex w-full max-w-sm flex-col gap-4"
>
<FormField
name="slot"
label="Yetkazib berish vaqti"
render={({ value, onValueChange, controlProps, invalid }) => (
<div {...controlProps} role="radiogroup" className="grid grid-cols-2 gap-2">
{slots.map((slot) => (
<button
key={slot}
type="button"
role="radio"
aria-checked={value === slot}
onClick={() => onValueChange(slot)}
className={cn(
'rounded-lg border px-3 py-2 text-sm tabular-nums',
value === slot
? 'border-primary bg-primary-soft text-primary-soft-foreground'
: 'border-border bg-surface text-text',
invalid && value !== slot && 'border-danger',
)}
>
{slot}
</button>
))}
</div>
)}
/>
<FormSubmit>Tasdiqlash</FormSubmit>
</Form>
);
}
<FormField
name="city"
label="City"
render={({ value, onValueChange, controlProps }) => (
<select {...controlProps} value={value} onChange={(e) => onValueChange(e.target.value)}>
<option value="tsh">Tashkent</option>
</select>
)}
/>Your own components
useFormField() lets a component inside FormField connect itself. Give such a child
adapter="none" so no props are injected:
function Stepper() {
const { value, onValueChange, id, invalid } = useFormField<number>();
return (
<button type="button" id={id} aria-invalid={invalid} onClick={() => onValueChange(value + 1)}>
{value}
</button>
);
}
<FormField name="guests" label="Guests" adapter="none">
<Stepper />
</FormField>;createFormAdapter() handles a component with different prop names:
import { createFormAdapter } from '@akhatmirzo/ui/form';
const pickerAdapter = createFormAdapter({
valueProp: 'selected',
changeProp: 'onSelect',
empty: [],
});
<FormField name="ids" label="Products" adapter={pickerAdapter}>
<ProductPicker />
</FormField>;Built-in names: adapter="native" (value + onChange(event)), "value", "checked",
"none".
Props
Form
All other <form> attributes are accepted too.
| Prop | Type | Default | Description |
|---|---|---|---|
formrequired | UseFormReturn<TFieldValues, TContext, TTransformedValues> | — | The result of useForm(...) |
onSubmitrequired | SubmitHandler<TTransformedValues> | — | Called with validated (resolver-transformed) values |
onInvalid | SubmitErrorHandler<TFieldValues> | — | Called with validation errors |
FormField
Also accepts label, description, required, disabled, orientation, className and
the other Field props (except error — it comes from the form).
| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | FieldPath<TFieldValues> | — | Field name |
children | ReactElement | — | A single control — props are injected automatically |
render | (field: FormFieldRenderProps) => ReactNode | — | Fallback: render the element yourself |
adapter | FormFieldAdapter | 'native' | 'nativeChecked' | 'value' | 'checked' | 'none' | — | How the value is passed; automatic by default (compatibility table) |
control | Control<TFieldValues> | — | Defaults to the nearest <Form> |
rules | RegisterOptions | — | react-hook-form rules |
defaultValue | FieldPathValue | — | Initial value of the field |
shouldUnregister | boolean | — | Drop the value when unmounted |
FormSubmit
Button props; type="submit", loading while submitting.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | ButtonVariant | 'primary' |
FormRootError
Alert props. Renders nothing when there is no error.
| Prop | Type | Default | Description |
|---|---|---|---|
color | 'neutral' | 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'danger' | |
children | (message: string) => ReactNode | — | Custom content instead of the text |
useFormField / render argument
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | |
value | TValue | — | Current form value |
onValueChange | (value: TValue) => void | — | |
onBlur | () => void | — | Marks the field as touched |
invalid | boolean | — | |
error | string | undefined | — | Error message |
id | string | — | The id the label points to |
disabled | boolean | — | |
controlProps | { id; 'aria-describedby'?; 'aria-invalid'?; 'aria-required'? } | — | render argument only — spread onto a native element |
Accessibility
- Label and error are linked. Every field gets
<label htmlFor>,aria-describedby(description + error) andaria-invalidthroughField; native elements andrenderget the same attributes. - Errors are announced. The error text lives in an
aria-live="polite"region, so screen readers read it after submit.FormRootErrorusesrole="alert". - Focus moves to the error. On submit the first invalid field's main element is focused
(
shouldFocusError), so keyboard users can fix it right away. - Browser validation is off (
noValidate) — messages look the same everywhere and are in Uzbek. - Portalled components (Select, Combobox, DateRangePicker) don't count as "left" when their popup opens,
so with
mode: 'onBlur'the error doesn't appear too early.