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