This guide explains how to properly organize your form handling workflow with type safety and dynamic behaviors like Create and Edit modes.
Step | Description | File |
---|---|---|
1️⃣ | Create Types and Schema | src/schema/userSchema.ts |
2️⃣ | Create Mutation Hook | src/hooks/useUserMutations.ts |
3️⃣ | Build the Form Component | src/components/UserForm.tsx |
4️⃣ | Use the Form Dynamically | src/pages/users.tsx |
File:
src/schema/userSchema.ts
import { z } from "zod";
export const userSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(1, "Age must be a positive number"),
id: z.string().optional(), // only needed for Edit mode
});
export type UserFormValues = z.infer<typeof userSchema>;
File:
src/hooks/useUserMutations.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UserFormValues } from "@/schema/userSchema";
const createUser = async (data: UserFormValues) => {
// API POST call to create a user
};
const updateUser = async (data: UserFormValues) => {
// API PUT call to update a user
};
export const useUserMutations = () => {
const queryClient = useQueryClient();
const create = useMutation({
mutationFn: createUser,
onSuccess: () => queryClient.invalidateQueries(["users"]),
});
const update = useMutation({
mutationFn: updateUser,
onSuccess: () => queryClient.invalidateQueries(["users"]),
});
return { create, update };
};
File:
src/components/UserForm.tsx
import { useForm, useEffect } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { userSchema, UserFormValues } from "@/schema/userSchema";
import { useUserMutations } from "@/hooks/useUserMutations";
interface UserFormProps {
mode: "create" | "edit";
defaultValues?: Partial<UserFormValues>;
}
export const UserForm = ({ mode, defaultValues }: UserFormProps) => {
const { register, handleSubmit, reset, formState: { errors } } = useForm<UserFormValues>({
resolver: zodResolver(userSchema),
});
const { create, update } = useUserMutations();
useEffect(() => {
if (defaultValues) {
reset(defaultValues);
}
}, [defaultValues, reset]);
const onSubmit = (data: UserFormValues) => {
if (mode === "edit") {
update.mutate(data);
} else {
create.mutate(data);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} placeholder="Name" />
{errors.name && <p>{errors.name.message}</p>}
<input {...register("email")} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input type="number" {...register("age")} placeholder="Age" />
{errors.age && <p>{errors.age.message}</p>}
{mode === "edit" && <input type="hidden" {...register("id")} />}
<button type="submit">{mode === "edit" ? "Update" : "Create"}</button>
</form>
);
};
File:
src/pages/users.tsx
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { UserForm } from "@/components/UserForm";
export default function UsersPage() {
const [userId, setUserId] = useState<string | null>(null);
const { data: user, isLoading } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
enabled: !!userId,
});
return (
<div>
{userId && !isLoading && user ? (
<UserForm mode="edit" defaultValues={user} />
) : (
<UserForm mode="create" />
)}
</div>
);
}
src/
├── components/
│ └── UserForm.tsx
├── hooks/
│ └── useUserMutations.ts
├── schema/
│ └── userSchema.ts
└── pages/
└── users.tsx
- Always call
reset()
withdefaultValues
in edit mode. - Use
enabled: !!userId
in dynamic queries to prevent unnecessary API calls. - Always invalidate related queries after mutation to refresh UI.
- Keep all form types and schemas centralized for reusability.
- Handle mutation errors gracefully with
onError
inuseMutation
.