Skip to Content
ExamplesForm with API Example

Form with API Example

폼 검증과 API 통합 예제


개요

React Hook Form과 Zod를 활용한 완전한 폼 검증 및 API 통합 예제입니다.

주요 특징

  • ✅ React Hook Form 통합
  • ✅ Zod 스키마 검증
  • ✅ 실시간 유효성 검사
  • ✅ API 에러 처리
  • ✅ 로딩 상태 관리

전체 코드

"use client"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { Button } from "@vortex/ui-foundation"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; import { Container } from "@/components/ui/container"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Spinner } from "@/components/ui/spinner"; // Zod 스키마 정의 const formSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), email: z.string().email("Invalid email address"), phone: z .string() .regex(/^\d{3}-\d{4}-\d{4}$/, "Phone must be in format: 010-1234-5678"), company: z.string().optional(), role: z.enum(["developer", "designer", "manager", "other"], { required_error: "Please select a role", }), message: z.string().min(10, "Message must be at least 10 characters"), subscribe: z.boolean().default(false), terms: z.boolean().refine((val) => val === true, { message: "You must accept the terms and conditions", }), }); type FormData = z.infer<typeof formSchema>; export default function FormWithAPI() { const [submitStatus, setSubmitStatus] = useState< "idle" | "success" | "error" >("idle"); const [errorMessage, setErrorMessage] = useState(""); const { register, handleSubmit, formState: { errors, isSubmitting }, reset, setValue, watch, } = useForm<FormData>({ resolver: zodResolver(formSchema), defaultValues: { subscribe: false, terms: false, }, }); const onSubmit = async (data: FormData) => { try { setSubmitStatus("idle"); setErrorMessage(""); // API 호출 시뮬레이션 const response = await fetch("/api/contact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to submit form"); } setSubmitStatus("success"); reset(); // 3초 후 성공 메시지 숨기기 setTimeout(() => setSubmitStatus("idle"), 3000); } catch (error) { setSubmitStatus("error"); setErrorMessage( error instanceof Error ? error.message : "An error occurred" ); } }; return ( <Container size="md" className="py-8"> <Card> <CardHeader> <CardTitle>Contact Form</CardTitle> <CardDescription> Fill out the form below and we'll get back to you as soon as possible. </CardDescription> </CardHeader> <CardContent> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> {/* Name */} <div className="space-y-2"> <Label htmlFor="name"> Name <span className="text-destructive">*</span> </Label> <Input id="name" placeholder="John Doe" {...register("name")} aria-invalid={errors.name ? "true" : "false"} /> {errors.name && ( <p className="text-sm text-destructive"> {errors.name.message} </p> )} </div> {/* Email */} <div className="space-y-2"> <Label htmlFor="email"> Email <span className="text-destructive">*</span> </Label> <Input id="email" type="email" placeholder="john@example.com" {...register("email")} aria-invalid={errors.email ? "true" : "false"} /> {errors.email && ( <p className="text-sm text-destructive"> {errors.email.message} </p> )} </div> {/* Phone */} <div className="space-y-2"> <Label htmlFor="phone"> Phone <span className="text-destructive">*</span> </Label> <Input id="phone" placeholder="010-1234-5678" {...register("phone")} aria-invalid={errors.phone ? "true" : "false"} /> {errors.phone && ( <p className="text-sm text-destructive"> {errors.phone.message} </p> )} </div> {/* Company */} <div className="space-y-2"> <Label htmlFor="company">Company</Label> <Input id="company" placeholder="Acme Inc." {...register("company")} /> </div> {/* Role */} <div className="space-y-2"> <Label htmlFor="role"> Role <span className="text-destructive">*</span> </Label> <Select onValueChange={(value) => setValue("role", value as any)} defaultValue={watch("role")} > <SelectTrigger id="role"> <SelectValue placeholder="Select your role" /> </SelectTrigger> <SelectContent> <SelectItem value="developer">Developer</SelectItem> <SelectItem value="designer">Designer</SelectItem> <SelectItem value="manager">Manager</SelectItem> <SelectItem value="other">Other</SelectItem> </SelectContent> </Select> {errors.role && ( <p className="text-sm text-destructive"> {errors.role.message} </p> )} </div> {/* Message */} <div className="space-y-2"> <Label htmlFor="message"> Message <span className="text-destructive">*</span> </Label> <Textarea id="message" placeholder="Tell us about your project..." rows={5} {...register("message")} aria-invalid={errors.message ? "true" : "false"} /> {errors.message && ( <p className="text-sm text-destructive"> {errors.message.message} </p> )} </div> {/* Subscribe */} <div className="flex items-center space-x-2"> <Checkbox id="subscribe" checked={watch("subscribe")} onCheckedChange={(checked) => setValue("subscribe", checked as boolean) } /> <Label htmlFor="subscribe" className="font-normal cursor-pointer"> Subscribe to our newsletter </Label> </div> {/* Terms */} <div className="flex items-start space-x-2"> <Checkbox id="terms" checked={watch("terms")} onCheckedChange={(checked) => setValue("terms", checked as boolean) } aria-invalid={errors.terms ? "true" : "false"} /> <div className="grid gap-1.5 leading-none"> <Label htmlFor="terms" className="font-normal cursor-pointer"> I accept the{" "} <a href="/terms" className="text-primary underline"> terms and conditions </a>{" "} <span className="text-destructive">*</span> </Label> {errors.terms && ( <p className="text-sm text-destructive"> {errors.terms.message} </p> )} </div> </div> {/* Status Messages */} {submitStatus === "success" && ( <Alert> <AlertDescription> ✅ Form submitted successfully! We'll get back to you soon. </AlertDescription> </Alert> )} {submitStatus === "error" && ( <Alert variant="destructive"> <AlertDescription>❌ {errorMessage}</AlertDescription> </Alert> )} {/* Submit Button */} <Button type="submit" className="w-full" disabled={isSubmitting}> {isSubmitting && <Spinner size="sm" className="mr-2" />} {isSubmitting ? "Submitting..." : "Submit"} </Button> </form> </CardContent> </Card> {/* Form State Debug */} <Card className="mt-6"> <CardHeader> <CardTitle>Form State (Debug)</CardTitle> </CardHeader> <CardContent> <pre className="text-xs bg-muted p-4 rounded overflow-x-auto"> {JSON.stringify({ values: watch(), errors }, null, 2)} </pre> </CardContent> </Card> </Container> ); }

Zod 스키마 패턴

기본 검증

const schema = z.object({ email: z.string().email("Invalid email"), password: z.string().min(8, "Password must be at least 8 characters"), age: z.number().min(18, "Must be 18 or older"), });

커스텀 검증

const schema = z .object({ password: z.string(), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], });

조건부 검증

const schema = z .object({ role: z.enum(["user", "admin"]), adminCode: z.string().optional(), }) .refine( (data) => { if (data.role === "admin") { return data.adminCode && data.adminCode.length > 0; } return true; }, { message: "Admin code is required for admin role", path: ["adminCode"], } );

API 통합 패턴

기본 POST 요청

const onSubmit = async (data: FormData) => { try { const response = await fetch("/api/form", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (!response.ok) { throw new Error("Failed to submit"); } const result = await response.json(); console.log("Success:", result); } catch (error) { console.error("Error:", error); } };

TanStack Query 사용

import { useMutation } from "@tanstack/react-query"; function ContactForm() { const mutation = useMutation({ mutationFn: async (data: FormData) => { const response = await fetch("/api/contact", { method: "POST", body: JSON.stringify(data), }); if (!response.ok) throw new Error("Failed"); return response.json(); }, onSuccess: () => { alert("Form submitted!"); reset(); }, onError: (error) => { alert(`Error: ${error.message}`); }, }); const onSubmit = (data: FormData) => { mutation.mutate(data); }; return ( <form onSubmit={handleSubmit(onSubmit)}> {/* ... */} <Button disabled={mutation.isPending}> {mutation.isPending ? "Submitting..." : "Submit"} </Button> </form> ); }

Server Actions (Next.js)

// app/actions.ts "use server"; export async function submitContactForm(data: FormData) { const validatedData = formSchema.parse(data); await db.contacts.create({ data: validatedData, }); return { success: true }; }
// Form component import { submitContactForm } from "./actions"; function ContactForm() { const onSubmit = async (data: FormData) => { const result = await submitContactForm(data); if (result.success) { alert("Form submitted!"); } }; return <form onSubmit={handleSubmit(onSubmit)}>...</form>; }

에러 처리

서버 에러 매핑

const onSubmit = async (data: FormData) => { try { const response = await fetch("/api/form", { method: "POST", body: JSON.stringify(data), }); if (!response.ok) { const error = await response.json(); // 필드별 에러 설정 if (error.fieldErrors) { Object.entries(error.fieldErrors).forEach(([field, message]) => { setError(field as any, { type: "server", message: message as string, }); }); } throw new Error(error.message); } } catch (error) { setError("root", { message: error instanceof Error ? error.message : "Unknown error", }); } };

네트워크 에러

const onSubmit = async (data: FormData) => { try { const response = await fetch("/api/form", { method: "POST", body: JSON.stringify(data), signal: AbortSignal.timeout(10000), // 10초 타임아웃 }); // ... } catch (error) { if (error instanceof TypeError) { setError("root", { message: "Network error. Please check your connection.", }); } else if (error.name === "AbortError") { setError("root", { message: "Request timeout. Please try again." }); } else { setError("root", { message: "An unexpected error occurred." }); } } };

로딩 상태 관리

버튼 상태

<Button type="submit" disabled={isSubmitting}> {isSubmitting && <Spinner size="sm" className="mr-2" />} {isSubmitting ? "Submitting..." : "Submit"} </Button>

폼 전체 비활성화

<fieldset disabled={isSubmitting}> <Input {...register("name")} /> <Input {...register("email")} /> <Button type="submit">Submit</Button> </fieldset>

진행률 표시

function MultiStepForm() { const [step, setStep] = useState(1); const totalSteps = 3; return ( <div> <div className="mb-6"> <div className="flex justify-between mb-2"> <span className="text-sm"> Step {step} of {totalSteps} </span> <span className="text-sm"> {Math.round((step / totalSteps) * 100)}% </span> </div> <div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="h-full bg-primary transition-all" style={{ width: `${(step / totalSteps) * 100}%` }} /> </div> </div> {/* Form steps */} </div> ); }

파일 업로드

단일 파일

const schema = z.object({ file: z .instanceof(File) .refine((file) => file.size <= 5000000, "File size must be less than 5MB") .refine( (file) => ["image/jpeg", "image/png"].includes(file.type), "Only .jpg and .png files are accepted" ), }); function FileUploadForm() { const { register, handleSubmit } = useForm({ resolver: zodResolver(schema), }); const onSubmit = async (data) => { const formData = new FormData(); formData.append("file", data.file[0]); await fetch("/api/upload", { method: "POST", body: formData, }); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <Input type="file" {...register("file")} /> <Button type="submit">Upload</Button> </form> ); }

다중 파일

const schema = z.object({ files: z .instanceof(FileList) .refine((files) => files.length <= 5, "Maximum 5 files allowed") .refine( (files) => Array.from(files).every((file) => file.size <= 5000000), "Each file must be less than 5MB" ), });

실시간 검증

필드별 검증 모드

const { register, handleSubmit } = useForm({ mode: "onChange", // onChange, onBlur, onSubmit, onTouched, all reValidateMode: "onChange", });

비동기 검증

const schema = z.object({ username: z.string().refine( async (username) => { const response = await fetch(`/api/check-username?username=${username}`); const { available } = await response.json(); return available; }, { message: "Username is already taken" } ), });

접근성

ARIA 속성

<Input {...register("email")} aria-invalid={errors.email ? "true" : "false"} aria-describedby={errors.email ? "email-error" : undefined} />; { errors.email && ( <p id="email-error" role="alert" className="text-destructive"> {errors.email.message} </p> ); }

필수 필드 표시

<Label htmlFor="name"> Name{" "} <span className="text-destructive" aria-label="required"> * </span> </Label>

다음 단계

Last updated on