robust-form-architecture
Skill2 starsChanged 5 months agoMITView on GitHub
Form state management, validation, and submission architecture for Next.js + React Hook Form + Zod + Shadcn UI + Convex. Triggers on "create form", "form validation", "handleSubmit", "react hook form", "zod schema", "form error handling", "shadcn form", or "mutation form". Enforces schema-first validation, uncontrolled components with defaultValues, and defensive Convex submission pipelines with error mapping.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- PURPLE-ORCA/skillsword for word, first seen Jun 20, 2026
Other versions1 project uses a version of this.
Word for word · 1
- PURPLE-ORCA/skills★ 2100% same
Edited · 0
None found.
Wrote their own version · 0
None found.
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository7 other files it gives its agents.
- antigravitySkill
- cdp-browser-automationSkill
- hyperframes-promoSkill
- library-fit-potential-use-captureSkill
- mirageSkill
- pragmatic-architectureSkill
- universal-docsSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 26
When instructed to build a form:
- Very specific: line 29
The Defaults: Create a defaultValues object that mirrors the schema exactly.
- Very specific: line 32
The Submission Handler: Write an async onSubmit that:
- Distinctive: line 257
Before marking a form as complete, verify:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Robust Form Architecture
Forms are data contracts, not UI decorations. Developers routinely ship broken forms that leak server errors to the console, allow double-submissions, and fail to map backend failures back to the user interface.
This skill enforces a defensive, schema-first form architecture that treats every submission as an async transaction. We do not trust manual state. We do not trust implicit defaults. We handle errors explicitly at every boundary.
DO / DON'T Quick Reference
| DO (Defensive Forms) | DON'T (The Bug Factory) | Why |
Begin with a z.object() schema defining every field | Start with useState and onChange handlers | Schema is the source of truth. Manual state drifts and breeds validation bugs. |
Use useForm({ resolver: zodResolver(schema), defaultValues: { ... } }) | Call useState for individual fields or skip defaultValues | Uncontrolled components with explicit defaultValues prevent hydration mismatches and React controlled/uncontrolled warnings. |
Compose with Shadcn <FormField>, <FormControl>, <FormMessage> | Write custom <input> with manual value/onChange | Shadcn Form components handle error display, accessibility, and field registration automatically. |
Disable submit button via form.formState.isSubmitting | Manually track isLoading with useState | RHF's isSubmitting integrates with the handleSubmit lifecycle. Manual state misses edge cases. |
Wrap mutation in try/catch, map errors with form.setError("root", ...) | Let errors throw to console or show generic alerts | Users need specific feedback. setError maps server failures directly to the form UI. |
Call form.reset() on successful submission | Leave form data populated after success | Reset prevents accidental resubmissions and signals completion. |
Execution Protocol
When instructed to build a form:
- The Schema: Define a
z.object()with every field explicitly typed. Export it for reuse in API contracts. - The Defaults: Create a
defaultValuesobject that mirrors the schema exactly. Every field must have an initial value (empty string, false, null, etc.). - The Hook: Initialize
useFormwithresolver: zodResolver(schema)and the completedefaultValues. - The UI Composition: Wrap the form in
<Form {...form}>. Use<FormField>for each input withrender={({ field }) => (...)}. Render<FormMessage />below each field. - The Submission Handler: Write an async
onSubmitthat:- Wraps the mutation call in
try/catch - On error: calls
form.setError("root", { message: error.message || "Submission failed" }) - On success: calls
form.reset()
- Wraps the mutation call in
- The Submit Button: Disable it when
form.formState.isSubmittingor!form.formState.isValid. - The Root Error: Render a
<FormMessage />or alert forform.formState.errors.rootbelow the submit button.
The Edge Cases / Anti-Patterns
When NOT to use this skill:
- Simple Search Inputs: A single text input that filters a list in real-time (no submit action) can use controlled state. This skill is for data-submission forms.
- File Uploads: File inputs require special handling beyond standard RHF registration. Use a dedicated file upload pattern with
useControlleror a specialized library. - WYSIWYG Editors: Rich text editors (TipTap, Quill) have their own state management. Wrap them with
Controllerfrom RHF, not standard<FormField>.
The useState Ban Exception:
You may use useState for UI-only state that does not affect form data (e.g., toggling a password visibility icon, expanding an accordion). Never use it for field values.
Dynamic Field Arrays:
For forms with dynamic fields (adding/removing items), use RHF's useFieldArray hook. The same schema and defaultValues rules apply, but wrap the array in z.array().
The Complete Pipeline
// 1. THE SCHEMA (lib/schemas/userProfile.ts)
import { z } from "zod";
export const userProfileSchema = z.object({
fullName: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
bio: z.string().max(500, "Bio must be under 500 characters").optional(),
newsletter: z.boolean().default(false),
});
export type UserProfileFormData = z.infer<typeof userProfileSchema>;
// 2. THE FORM COMPONENT (app/settings/profile-form.tsx)
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
userProfileSchema,
type UserProfileFormData,
} from "@/lib/schemas/userProfile";
// 3. EXPLICIT DEFAULTVALUES - Every schema field must be present
const defaultValues: UserProfileFormData = {
fullName: "",
email: "",
bio: "",
newsletter: false,
};
export function ProfileForm() {
// 4. THE HOOK with resolver and complete defaultValues
const form = useForm<UserProfileFormData>({
resolver: zodResolver(userProfileSchema),
defaultValues,
mode: "onBlur", // Validate on blur for better UX
});
const updateProfile = useMutation(api.users.updateProfile);
// 5. THE SUBMISSION HANDLER with error mapping
async function onSubmit(data: UserProfileFormData) {
try {
await updateProfile(data);
// On success: reset the form
form.reset();
} catch (error: any) {
// Map server error to the form UI
const message = error?.data || error?.message || "Failed to update profile";
form.setError("root", { message });
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Root-level error display */}
{form.formState.errors.root && (
<Alert variant="destructive">
<AlertDescription>
{form.formState.errors.root.message}
</AlertDescription>
</Alert>
)}
{/* 6. THE UI COMPOSITION with FormField, FormControl, FormMessage */}
<FormField
control={form.control}
name="fullName"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Jane Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="jane@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us about yourself..."
className="min-h-[100px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newsletter"
render={({ field }) => (
<FormItem className="flex items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Subscribe to newsletter</FormLabel>
</div>
</FormItem>
)}
/>
{/* 7. THE SUBMIT BUTTON - disabled during submission */}
<Button
type="submit"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? "Saving..." : "Save Profile"}
</Button>
</form>
</Form>
);
}Field-Specific Server Errors
For Convex mutations that return field-level validation (e.g., "email already exists"):
// In the mutation (Convex)
export const updateProfile = mutation({
args: { email: v.string(), fullName: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_email", q => q.eq("email", args.email))
.unique();
if (existing && existing._id !== ctx.user._id) {
throw new ConvexError({
fieldErrors: { email: "Email already in use" }
});
}
// ... save logic
},
});
// In the form component
try {
await updateProfile(data);
form.reset();
} catch (error: any) {
const serverErrors = error?.data?.fieldErrors;
if (serverErrors) {
// Map each field error individually
Object.entries(serverErrors).forEach(([field, message]) => {
form.setError(field as keyof UserProfileFormData, { message: message as string });
});
} else {
form.setError("root", { message: error?.data || "Update failed" });
}
}Non-Negotiable Checklist
Before marking a form as complete, verify:
- Zod schema exported and imported
defaultValueshas every schema field explicitly defineduseFormuseszodResolveranddefaultValues- Every field uses
<FormField>with<FormControl>and<FormMessage> - Submit button disabled when
isSubmittingor!isValid try/catchwraps the mutationform.setErrormaps server errors to UIform.reset()called on success
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

