February 02, 2024
이 글에서는 shadcn + react-hook-form useFieldArray + zod로 동적 form을 만드는 과정을 소개한다.
책에서 액션 아이템을 뽑는 앱을 사이드 프로젝트로 만들고 있다. 그러다 form을 만들 일이 생겼다. 회사에서는 Vue를 주로 쓰고 있어서 React를 사이드 프로젝트로 공부하고 있다. 안 그래도 React는 form을 만들기가 어렵다는 얘기를 많이 들어서 살짝 겁났다.
먼저 내가 쓰고 있는 shadcn의 Form component를 살펴봤다. shadcn은 form을 만들 때 React Hook Form과 Zod를 사용한다. 공식문서를 따라가 보면 다음과 같다.
'use client'
import { z } from 'zod'
const formSchema = z.object({
username: z
.string()
.min(2)
.max(50),
})
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form
// 1. Define your form.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
})
// 2. Define a submit handler.
// values로 데이터를 확인할 수 있다.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
이 가이드를 기반으로 내가 작성한 코드는 다음과 같다.
'use client'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { X } from 'lucide-react'
type Props = {
children: React.ReactNode,
}
const FormSchema = z.object({
from: z.string().min(2, {
message: '출처는 반드시 필요합니다.',
}),
title: z.string().min(2, {
message: '제목은 반드시 필요합니다.',
}),
})
export default function ActionItemCreateDrawer({ children }: Props) {
const form =
useForm <
z.infer <
typeof FormSchema >>
{
resolver: zodResolver(FormSchema),
defaultValues: {
from: '',
title: '',
},
}
function onSubmit(data: z.infer<typeof FormSchema>) {
toast({
title: 'You submitted the following values:',
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Drawer>
<DrawerTrigger>{children}</DrawerTrigger>
<DrawerContent className="h-screen mt-0 max-w-sm mx-auto">
<DrawerHeader>
<DrawerClose>
<X className="mb-10" />
</DrawerClose>
<DrawerTitle>액션 아이템 만들기</DrawerTitle>
</DrawerHeader>
<div className="px-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>출처</FormLabel>
<FormControl>
<Input placeholder="부자아빠 가난한아빠" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>제목</FormLabel>
<FormControl>
<Input placeholder="힘든 일 먼저 하기" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
액션아이템 만들기
</Button>
</form>
</Form>
</div>
</DrawerContent>
</Drawer>
)
}
내 form은 string type인 from과 title field를 가지고 있다. 여기까진 좋았다.
생각해보니 액션 아이템이 여러개일 수 있다는 사실을 간과했다. 그래서 다급하게 react-hook-form dynamic
이라는 키워드를 검색했다. 찾아보니 useFieldArray라는 hook이 있었는데 이걸 쓰면 될 것 같았다. 참고해서 코드를 수정했다.
// actionItem -> actionItems 배열로 바뀌었다.
const FormSchema = z.object({
from: z.string().min(2, {
message: '출처는 반드시 필요합니다.',
}),
actionItems: z.array(
z.object({
actionItem: z.string().min(2, {
message: '액션 아이템은 반드시 필요합니다.',
}),
})
),
})
// initValue도 그에 맞게 수정해줬다.
const initValue = {
from: '',
actionItems: [{ actionItem: '' }],
}
const form =
useForm <
z.infer <
typeof FormSchema >>
{
resolver: zodResolver(FormSchema),
defaultValues: initValue,
}
// useFieldArray의 control에는 기존의 form.control을 넘긴다.
// name은 이 hook으로 관리하는 field array의 이름을 넘겨주면 된다.
// fields는 관리하는 field들, append는 field를 추가할 때, remove는 제거할 때 사용할 수 있다.
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'actionItems',
})
UI 부분은 다음과 같이 수정했다. name에는 아래와 같이 형식에 맞춰서 적어줘야 한다. 형식이 다르면 Input component에서 type error가 났다.
append를 할 수 있는 Button도 추가해봤다.
{
fields.map((field, index) => {
return (
<FormField
key={`form-field-${field.id}-${index}`}
control={form.control}
name={`actionItems.${index}.actionItem`}
render={({ field }) => (
<FormItem>
<FormLabel>액션 아이템</FormLabel>
<FormControl>
<Input placeholder="힘든 일 먼저 하기" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
})
}
;<div className="flex justify-end">
<Button onClick={() => append({ actionItem: '' })}>추가하기</Button>
</div>
최종 코드는 다음과 같다.
'use client'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { toast } from '@/components/ui/use-toast'
import { Input } from '@/components/ui/input'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm, useFieldArray } from 'react-hook-form'
import { X } from 'lucide-react'
type Props = {
children: React.ReactNode,
open: boolean,
setOpen: (open: boolean) => void,
}
const FormSchema = z.object({
from: z.string().min(2, {
message: '출처는 반드시 필요합니다.',
}),
actionItems: z.array(
z.object({
actionItem: z.string().min(2, {
message: '액션 아이템은 반드시 필요합니다.',
}),
})
),
})
const initValue = {
from: '',
actionItems: [{ actionItem: '' }],
}
export default function ActionItemCreateDrawer({
children,
open,
setOpen,
}: Props) {
const form =
useForm <
z.infer <
typeof FormSchema >>
{
resolver: zodResolver(FormSchema),
defaultValues: initValue,
}
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'actionItems',
})
function onSubmit(data: z.infer<typeof FormSchema>) {
setOpen(false)
toast({
title: 'You submitted the following values:',
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger>{children}</DrawerTrigger>
<DrawerContent className="h-screen mt-0 max-w-sm mx-auto">
<DrawerHeader>
<DrawerClose>
<X className="mb-10" />
</DrawerClose>
<DrawerTitle>액션 아이템 만들기</DrawerTitle>
</DrawerHeader>
<div className="px-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-full space-y-6"
>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>출처</FormLabel>
<FormControl>
<Input placeholder="부자아빠 가난한아빠" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{fields.map((field, index) => {
return (
<FormField
key={`form-field-${field.id}`}
control={form.control}
name={`actionItems.${index}.actionItem`}
render={({ field }) => (
<FormItem>
<FormLabel>액션 아이템</FormLabel>
<FormControl>
<Input placeholder="힘든 일 먼저 하기" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
})}
<div className="flex justify-end">
<Button onClick={() => append({ actionItem: '' })}>
추가하기
</Button>
</div>
<Button type="submit" className="w-full">
액션아이템 만들기
</Button>
</form>
</Form>
</div>
</DrawerContent>
</Drawer>
)
}
멋진 라이브러리들의 도움으로 동적 form을 손쉽게 만들어봤다. actionItems를 string 배열로 만들어보고 싶었는데 하다보니 타입 에러가 나서 이건 아직 구현 방법을 모르겠다. 손에 익도록 다양한 형태의 form도 만들어봐야겠다.