shadcn, react-hook-form useFieldArray, zod로 동적 form 관리하기

이 글에서는 shadcn + react-hook-form useFieldArray + zod로 동적 form을 만드는 과정을 소개한다.

  • shadcn: UI library
  • react-hook-form: 폼을 만드는 라이브러리
  • zod: 스키마 타입 검증을 해주는 라이브러리

책에서 액션 아이템을 뽑는 앱을 사이드 프로젝트로 만들고 있다. 그러다 form을 만들 일이 생겼다. 회사에서는 Vue를 주로 쓰고 있어서 React를 사이드 프로젝트로 공부하고 있다. 안 그래도 React는 form을 만들기가 어렵다는 얘기를 많이 들어서 살짝 겁났다.

먼저 내가 쓰고 있는 shadcn의 Form component를 살펴봤다. shadcn은 form을 만들 때 React Hook Form과 Zod를 사용한다. 공식문서를 따라가 보면 다음과 같다.

  1. 먼저 form의 스키마를 만든다. 이 예제에서 username은 최소 2, 최대 50의 길이를 가지는 문자열이다.
'use client'

import { z } from 'zod'

const formSchema = z.object({
  username: z
    .string()
    .min(2)
    .max(50),
})
  1. useForm hook으로 form을 만든다.
"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)
}
  1. 컴포넌트는 아래와 같이 구성하면 form이 연동된다.
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를 가지고 있다. 여기까진 좋았다.

Untitled

생각해보니 액션 아이템이 여러개일 수 있다는 사실을 간과했다. 그래서 다급하게 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>
  )
}

화면 기록 2024-01-30 오후 10 34 57

스크린샷 2024-01-30 오후 10.35.19.png

결론

멋진 라이브러리들의 도움으로 동적 form을 손쉽게 만들어봤다. actionItems를 string 배열로 만들어보고 싶었는데 하다보니 타입 에러가 나서 이건 아직 구현 방법을 모르겠다. 손에 익도록 다양한 형태의 form도 만들어봐야겠다.


Written by@Donghoon Song
사람들의 꿈을 이어주는 코멘토에서 일하고 있습니다.

InstagramGitHubTwitterLinkedIn