react-hook-form
Wire newt/ui's form controls to react-hook-form, with zod for validation.
Install
pnpm dlx --yes react-hook-form @hookform/resolvers zodnpx --yes react-hook-form @hookform/resolvers zodyarn dlx --yes react-hook-form @hookform/resolvers zodbunx --yes react-hook-form @hookform/resolvers zodpnpm dlx newtui@latest add form-fields buttonnpx newtui@latest add form-fields buttonyarn dlx newtui@latest add form-fields buttonbunx newtui@latest add form-fields buttonThe schema
The schema is the contract. Nothing below restates a rule that is written here.
import { z } from "zod"
export const channelSchema = z.object({
name: z
.string()
.min(2, "At least two characters.")
.max(32, "At most 32 characters.")
.regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers and dashes only."),
topic: z.string().max(120, "At most 120 characters.").optional(),
category: z.enum(["text", "voice", "announcement"]),
nsfw: z.boolean(),
})
export type ChannelValues = z.infer<typeof channelSchema>
The form
register returns the props a native control needs, which is exactly what
these controls are — so it goes straight on.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import {
Checkbox,
Field,
FieldHelp,
Input,
Label,
Select,
Textarea,
} from "@/components/ui/form-fields"
import { channelSchema, type ChannelValues } from "@/lib/channel-schema"
export function CreateChannelForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ChannelValues>({
resolver: zodResolver(channelSchema),
defaultValues: { name: "", topic: "", category: "text", nsfw: false },
})
return (
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(async (values) => {
await createChannel(values)
reset()
})}
>
<Field invalid={Boolean(errors.name)}>
<Label htmlFor="name">Channel name</Label>
<Input
id="name"
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name ? (
<FieldHelp id="name-error" variant="error">
{errors.name.message}
</FieldHelp>
) : (
<FieldHelp>Lowercase letters, numbers and dashes.</FieldHelp>
)}
</Field>
<Field invalid={Boolean(errors.topic)}>
<Label htmlFor="topic">Topic</Label>
<Textarea id="topic" {...register("topic")} />
{errors.topic && (
<FieldHelp variant="error">{errors.topic.message}</FieldHelp>
)}
</Field>
<Field>
<Label htmlFor="category">Category</Label>
<Select id="category" {...register("category")}>
<option value="text">Text</option>
<option value="voice">Voice</option>
<option value="announcement">Announcement</option>
</Select>
</Field>
<Field>
<Checkbox id="nsfw" {...register("nsfw")} />
<Label htmlFor="nsfw">Age-restricted channel</Label>
</Field>
<Button type="submit" variant="primary" disabled={isSubmitting}>
{isSubmitting ? "Creating…" : "Create channel"}
</Button>
</form>
)
}
Validation timing
The default is onSubmit, which is the right one: validating a field the
reader has not finished typing in is noise. mode: "onBlur" is a reasonable
second choice; mode: "onChange" is rarely worth it.
useForm({ resolver: zodResolver(channelSchema), mode: "onBlur" })
Switch and other controlled parts
Switch is a button with aria-checked, not an <input>, so it goes through
Controller:
import { Controller } from "react-hook-form"
import { Switch } from "@/components/ui/form-fields"
;<Controller
name="nsfw"
control={control}
render={({ field }) => (
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
/>
Array fields
const { fields, append, remove } = useFieldArray({ control, name: "tags" })
;<>
{fields.map((entry, index) => (
<Field key={entry.id} invalid={Boolean(errors.tags?.[index])}>
<Label>Tag {index + 1}</Label>
<Input {...register(`tags.${index}.value`)} />
<Button type="button" variant="secondary" onClick={() => remove(index)}>
Remove
</Button>
</Field>
))}
<Button
type="button"
variant="secondary"
onClick={() => append({ value: "" })}
>
Add tag
</Button>
</>
entry.id is the key, not the index: removing the first row otherwise remounts
every row after it and loses focus.