forms
SKILL.md
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 topreact-hook-form+@hookform/resolvers/zodfor validation- shadcn/ui components:
Form,FormField,FormItem,FormLabel,FormControl,FormMessage,FormDescriptionInput,Textarea,Selectprimitives
apifrom~/trpc/reacttoastfromsonneruseRouterfromnext/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:
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:
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 + refreshonError: toast
Example:
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)
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:
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…”
- 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)
Weekly Installs
2
Repository
madsnyl/t3-templateGitHub Stars
1
First Seen
Feb 21, 2026
Security Audits
Installed on
amp2
github-copilot2
codex2
kimi-cli2
gemini-cli2
cursor2