Client Forms Skill: shadcn/ui + React Hook Form + Zod + tRPC + Sonner
You implement client components that render forms using shadcn/ui Form + react-hook-form with Zod validation, submit via tRPC mutations, and provide UX feedback with Sonner toasts plus Next.js router navigation + refresh.
This skill assumes:
- •Inputs are typed from Zod schemas (e.g.
type UpdateEventInput = z.infer<typeof updateEventSchema>). - •Entity props are typed using Prisma payload types aligned with the Prisma type-settings skill (Prisma.validator + GetPayload).
When to use this skill
Use this skill when the user asks to:
- •create/edit forms in Next.js client components
- •wire Zod schemas to react-hook-form
- •submit via tRPC
.useMutation()with success/error handling - •show toast confirmation and refresh/redirect afterwards
- •ensure the form’s types match Zod input + Prisma payload selection types
Canonical stack & imports
- •
"use client";at the top - •
react-hook-form+@hookform/resolvers/zodfor validation - •shadcn/ui components:
- •
Form,FormField,FormItem,FormLabel,FormControl,FormMessage,FormDescription - •
Input,Textarea,Selectprimitives
- •
- •
apifrom~/trpc/react - •
toastfromsonner - •
useRouterfromnext/navigation
Hard rules (must follow)
- •Zod-first typing
- •Use
zodResolver(schema)and a form generic of the inferred input type:- •
useForm<UpdateEventInput>({ resolver: zodResolver(updateEventSchema), ... })
- •
- •Use
- •No inline business logic in UI
- •The component handles form state + calling tRPC.
- •Domain logic belongs in server (services) or tRPC controllers.
- •Mutation UX contract
- •
onSuccess: show success toast + navigate (optional) + refresh data. - •
onError: show error toast witherror.message.
- •
- •Disable submit while pending
- •Use
mutation.isPendingto disable submit and show loading text.
- •Use
- •Entity prop types reference Prisma type-settings skill
- •Component props (
event,user, etc.) must be typed via Prisma payload types derived from sharedselect/includedefinitions. - •Do not hand-write “Event shape” interfaces that drift from Prisma selections.
- •Component props (
File conventions
- •Zod schemas live in:
- •
@src/schemas/<domain>.ts(or domain folder)
- •
- •Prisma payload selections/types live in:
- •
@src/types/<domain>/...(per Prisma type-settings skill)
- •
Standard form structure (the canonical pattern)
1) Type the entity prop using Prisma payload types
Preferred:
- •Keep the select/include in
types/<domain>/... - •Use
Prisma.<Model>GetPayload<{ select: typeof ... }>orGetPayload<typeof args>
Example:
ts
import type { Prisma } from "generated/prisma";
import { EventDetail } from "~/types/event";
// EventDetail should be a Prisma.validator() select/include defined in types/
type Event = Prisma.EventGetPayload<{ select: typeof EventDetail }>;
2) Initialize RHF with Zod + defaultValues from the entity
Rules:
- •Always provide default values for every form field you render.
- •For optional fields, use
?? ""for textareas/inputs.
Example:
ts
const form = useForm<UpdateEventInput>({
resolver: zodResolver(updateEventSchema),
defaultValues: {
id: event.id,
title: event.title,
rules: event.rules ?? "",
// ...
},
});
3) Create a tRPC mutation with toast + router flow
Preferred mutation pattern:
- •
onSuccess: toast + route + refresh - •
onError: toast
Example:
ts
const router = useRouter();
const updateEvent = api.event.update.useMutation({
onSuccess: () => {
toast.success("Event updated successfully!");
router.push("/admin/events");
router.refresh();
},
onError: (error) => toast.error(error.message),
});
4) Submit handler calls .mutate(values)
ts
const onSubmit = (values: UpdateEventInput) => updateEvent.mutate(values);
5) Use shadcn <Form> + <FormField> for accessibility & errors
- •Wrap
<form>inside<Form {...form}>. - •Use
<FormMessage />on each field.
This is aligned with shadcn’s recommended RHF + Zod pattern.
Cache refresh strategy (choose the right one)
Default (simple admin flows)
Use:
- •
router.push(...)+router.refresh()This revalidates the route and refetches server component data.
When staying on the same page with client queries
Prefer tRPC utils invalidation:
- •
const utils = api.useUtils(); - •
await utils.<path>.<proc>.invalidate()
Example:
ts
const utils = api.useUtils();
const updateEvent = api.event.update.useMutation({
onSuccess: async (_, input) => {
toast.success("Updated!");
await utils.event.byId.invalidate({ id: input.id });
},
});
Rule of thumb:
- •If the page is RSC-driven:
router.refresh() - •If the page is client-query-driven:
utils...invalidate()
UX rules
- •Submit button:
- •disabled when
mutation.isPending - •label changes to “Updating…” / “Saving…”
- •<Loader2 /> lucide icon added to text with spinning animation
- •disabled when
- •Always show a toast on success and error (Sonner).
- •Provide a “Cancel” button that navigates back if it is inside a confirmation dialog.
Anti-patterns (do not do)
- •❌ Missing schema validation (no
zodResolver) - •❌ Manually typing inputs instead of
z.infer<typeof schema> - •❌ Passing untyped/unknown entity shapes to the form component
- •❌ Letting
sortBy, enum values, or select options be arbitrary strings (must be schema-driven/whitelisted) - •❌ Doing server writes directly in the component (use tRPC mutation)
- •❌ Forgetting to disable submit while pending
What to output when asked to build a new form
Provide:
- •Zod schema + inferred input type (or confirm existing schema)
- •Prisma selection type for the entity prop (per Prisma type-settings skill)
- •Full form component (client) using shadcn Form + RHF
- •tRPC mutation wiring with toast + refresh/navigation
- •Any invalidation plan (
router.refreshvsuseUtils().invalidate)