Forms in React have always given me a headache. Every time I started a new project, setting up validation and default values felt like juggling several balls at once. And don't even get me started on updating forms later—if I changed one thing, I had to hunt through multiple files just to make sure everything still worked.
Then I started building some complex forms for multi-platform score configurations (think: Instagram and YouTube scoring systems), and the complexity got out of hand pretty fast. I knew I had to fix how I managed form validation and defaults, or I'd lose my mind.
TL;DR: Bundle your Zod schema and default values in a single custom hook to handle both create and edit forms without duplication. Skip to the code examples below if you're impatient like me!
The Problem: Form Duplication Headaches
If you've worked with React forms for any length of time, this probably sounds familiar:
- You create a form with validation
- Later, you need an "edit" version of the same form
- You copy most of your code but need to handle default values differently
- A requirement changes, and you have to update both forms
- You forget to update one of them, and bugs appear
- Repeat until you question your career choices
I was tired of maintaining nearly identical form configurations across different components. Especially when using libraries like React Hook Form and Zod, it felt like I was duplicating effort for no good reason.
The Solution: Custom Schema Hooks
Here's a stripped-down version of what my solution looks like:
import { useMemo } from "react"; import { z } from "zod"; export const useProfileSchema = ({ data = {} } = {}) => { return useMemo(() => { // Define validation schema once const schema = z.object({ username: z.string().min(1, { message: "Username is required" }), age: z.number().min(18, { message: "Must be at least 18" }), subscribe: z.boolean().default(false), }); // Create defaults based on existing data or empty values const defaults = { username: data?.username || "", age: data?.age || 18, subscribe: data?.subscribe || false, }; return { schema, defaults }; }, [data]); };
The magic happens with that data
parameter. When creating a new form, you don't pass anything. When editing an existing record, you pass in the current values. The hook handles the rest!
Putting It To Work
Once I had this pattern down, setting up forms became a breeze. I just call this hook, plug its schema into React Hook Form via the resolver, and use the default values for initializing the form:
import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useProfileSchema } from "./useProfileSchema"; const ProfileForm = ({ initialData = {}, onSubmit }) => { const { schema, defaults } = useProfileSchema({ data: initialData }); const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); return ( <form onSubmit={form.handleSubmit(onSubmit)}> <div className="form-group"> <label htmlFor="username">Username</label> <input id="username" {...form.register("username")} placeholder="Enter username" className="form-control" /> {form.formState.errors.username && ( <span className="error">{form.formState.errors.username.message}</span> )} </div> <div className="form-group"> <label htmlFor="age">Age</label> <input id="age" type="number" {...form.register("age", { valueAsNumber: true })} placeholder="Enter age" className="form-control" /> {form.formState.errors.age && ( <span className="error">{form.formState.errors.age.message}</span> )} </div> <div className="form-check"> <input id="subscribe" type="checkbox" {...form.register("subscribe")} className="form-check-input" /> <label htmlFor="subscribe" className="form-check-label"> Subscribe to newsletter </label> </div> <button type="submit" className="btn btn-primary mt-3"> {initialData.id ? "Update Profile" : "Create Profile"} </button> </form> ); };
The beautiful part? This same component works for both creating and editing profiles. All you need to change is whether you pass initialData or not:
// Create new profile <ProfileForm onSubmit={handleCreate} /> // Edit existing profile <ProfileForm initialData={existingProfile} onSubmit={handleUpdate} />
Real-World Benefits
After implementing this pattern across several projects, here's what I've gained:
- Single source of truth: Validation rules and defaults live together, so they never get out of sync
- DRY code: No more copy-pasting form logic between create and edit pages
- Easier maintenance: Need to change a validation rule? Do it once in the hook, not in multiple places
- Better code organization: All form-related logic has a clear home
- Smoother handoff: New team members can quickly understand how forms work in our codebase
Taking It Further: Advanced Patterns
For larger projects, I've extended this pattern in a few ways that have proven invaluable:
1. Adding TypeScript for Better Safety
Adding TypeScript definitions makes this pattern even more powerful:
// Define your schema type type ProfileFormData = { username: string; age: number; subscribe: boolean; }; export const useProfileSchema = ({ data = {} as Partial<ProfileFormData> } = {}) => { return useMemo(() => { const schema = z.object({ username: z.string().min(1, { message: "Username is required" }), age: z.number().min(18, { message: "Must be at least 18" }), subscribe: z.boolean().default(false), }); const defaults: ProfileFormData = { username: data?.username || "", age: data?.age || 18, subscribe: data?.subscribe || false, }; return { schema, defaults }; }, [data]); };
2. Adding Transformation Functions
Sometimes the data from your API doesn't match exactly what your form needs. Adding transform functions to your hook can solve this:
export const useProfileSchema = ({ data = {} } = {}) => { return useMemo(() => { // Schema definition... // Transform API data to form format const apiToForm = (apiData) => ({ username: apiData.user_name, age: apiData.user_age, subscribe: Boolean(apiData.newsletter_opt_in), }); // Transform form data back to API format const formToApi = (formData) => ({ user_name: formData.username, user_age: formData.age, newsletter_opt_in: formData.subscribe ? 1 : 0, }); const defaults = data ? apiToForm(data) : { username: "", age: 18, subscribe: false, }; return { schema, defaults, apiToForm, formToApi }; }, [data]); };
3. Supporting Conditional Validation
For more complex forms where validation rules change based on other fields:
export const usePaymentSchema = ({ data = {} } = {}) => { return useMemo(() => { const schema = z.object({ paymentMethod: z.enum(["credit", "bank"]), creditCardNumber: z.string().optional(), bankAccountNumber: z.string().optional(), }).refine((data) => { if (data.paymentMethod === "credit" && !data.creditCardNumber) { return false; } if (data.paymentMethod === "bank" && !data.bankAccountNumber) { return false; } return true; }, { message: "Please fill in the required payment details", path: ["paymentMethod"], }); // Rest of the hook... }, [data]); };
4. Form Reset and Initial Values
One challenge I encountered was handling form resets properly. Here's a pattern that works well:
const ProfileForm = ({ initialData = {}, onSubmit }) => { const { schema, defaults } = useProfileSchema({ data: initialData }); const form = useForm({ resolver: zodResolver(schema), defaultValues: defaults, }); // Reset form when initialData changes (useful in edit scenarios) useEffect(() => { form.reset(defaults); }, [initialData, form, defaults]); const handleReset = () => { form.reset(defaults); }; return ( <form onSubmit={form.handleSubmit(onSubmit)}> {/* Form fields... */} <div className="button-group"> <button type="submit" className="btn btn-primary"> {initialData.id ? "Update" : "Create"} </button> <button type="button" onClick={handleReset} className="btn btn-secondary"> Reset </button> </div> </form> ); };
Performance Considerations
For larger forms, you might want to optimize your hook further:
export const useProfileSchema = ({ data = {} } = {}) => { // Memoize the data to avoid unnecessary recalculations const memoizedData = useMemo(() => data, [ // Only include keys you care about data.username, data.age, data.subscribe ]); return useMemo(() => { // Schema and defaults logic return { schema, defaults }; }, [memoizedData]); // Use the memoized data };
Wrapping Up
Honestly, this small change made my workflow so much smoother. If you've ever felt frustrated juggling form state and validation across different files, give this pattern a shot. It saved me a lot of time and stress, and I bet it'll help you too.
The best part? This pattern works with any form library in React - not just React Hook Form. The principle of bundling schema and defaults in a custom hook is universally applicable.
What form patterns have you found useful in your React projects? Let me know in the comments!
Follow me for more React patterns and tips that make development smoother and more enjoyable. If this article helped you, consider sharing it with your team!
Top comments (0)