Profile Settings Form
Multi-section settings form covering text inputs, a character-counted textarea, a Radix Select, and Toggle Switches for notification preferences. Demonstrates using react-hook-form's Controller API for Radix UI components that don't expose native DOM inputs, plus isDirty detection to disable the save button when nothing has changed.
import { ProfileSettingsForm } from "@aetherstack/patterns"
Installation
terminal
pnpm add react-hook-form zod @hookform/resolversImport
import.tsx
import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import {
Button, Input, Label, Textarea,
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
Switch, Separator,
} from "@aetherstack/ui"Usage
profile-settings-form.tsx
const schema = z.object({
displayName: z.string().min(2),
username: z.string().min(3).regex(/^[a-z0-9_-]+$/),
bio: z.string().max(160).optional(),
website: z.string().url().or(z.literal("")).optional(),
timezone: z.string().min(1),
emailNotifications: z.boolean(),
marketingEmails: z.boolean(),
})
export function ProfileForm() {
const { register, handleSubmit, watch, control, formState } =
useForm({ resolver: zodResolver(schema), defaultValues: { ... } })
const bio = watch("bio", "")
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* Text inputs use register() */}
<Input id="displayName" {...register("displayName")} />
{/* Character counter */}
<span>{bio.length}/160</span>
<Textarea id="bio" {...register("bio")} />
{/* Radix Select needs Controller */}
<Controller
name="timezone"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="America/New_York">Eastern Time</SelectItem>
</SelectContent>
</Select>
)}
/>
{/* Switch also needs Controller */}
<Controller
name="emailNotifications"
control={control}
render={({ field }) => (
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
/>
{/* isDirty prevents saving unchanged data */}
<Button type="submit" disabled={formState.isSubmitting || !formState.isDirty}>
Save changes
</Button>
</form>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| Controller | react-hook-form | — | Required for Radix UI components (Select, Switch, RadioGroup, Checkbox) that manage state internally rather than through a native DOM input. Controller bridges the gap by providing field.value and field.onChange. |
| defaultValues | Partial<FormValues> | — | Pre-fills the form on first render. react-hook-form uses these to calculate isDirty — fields are considered dirty only when their current value differs from the default. |
| isDirty | boolean | — | True when any field has changed from its default value. Used to disable the Save and Discard buttons, preventing unnecessary API calls. |
| watch | UseFormWatch | — | Subscribes to the bio field to render the live character count (current/max) without re-running validation on every keystroke. |
| website validation | z.string().url().or(z.literal("")) | — | Using .or(z.literal('')) makes the field optional: an empty string bypasses URL validation, but any non-empty value must be a valid URL. |
* Required
Accessibility
- →All form controls have explicit <Label> associations via htmlFor/id.
- →The bio character counter is visible text — not hidden, so screen readers announce it in reading order.
- →Sections are marked with <h3> headings for navigation hierarchy.
- →Radix Select and Switch forward aria-invalid through the Controller render prop.
- →The Discard and Save buttons are both disabled when the form is pristine, reducing accidental interactions.