React Hook Form 더 잘 쓰고 싶어! - 공식 문서 TS, Advanced 탭 살펴보기

gydotb·2025년 2월 2일
1

독감자스터디

목록 보기
4/5

이번엔 React Hook Form 공식문서에서 제공하는 TS 탭 내 코드 일부(Type 설명 제외)와 Advanced 탭 내 항목 코드를 해체해봤다…

TS

Resolver

useFormschema validation option에 해당하며, 스키마 검증 라이브러리와 통합하기 위해 사용한다.

이름타입설명
valuesobject폼의 모든 입력값을 포함하는 객체
contextobjectuseForm 설정에서 제공할 수 있는 컨텍스트 객체.
변경 가능하며, 리렌더링될 때마다 업데이트될 수 있음.
options{ criteriaMode: string, fields: object, names: string[] }유효성 검사된 필드 정보, 필드 이름 목록, criteriaMode 설정을 포함하는 옵션 객체

useForm - resolver

상세 코드

코드 전문

type FormValues = {
  firstName: string
  lastName: string
}

FormValue type 설정. Form에서 등록할 스키마를 미리 정의한다. firstNamelastNameregister의 name으로 사용하게 된다.

const resolver: Resolver<FormValues> = async (values) => {
  return {
    values: values.firstName ? values : {},
    errors: !values.firstName
      ? {
          firstName: {
            type: "required",
            message: "This is required.",
          },
        }
      : {},
  }

다른 라이브러리를 이용하지 않고, react-hook-form 그 자체로만 작성할 때의 예제로, firstName에 값이 없으면 필수 항목이라 경고하는 에러 메시지를 표기한다.

resolver 이용 시의 주의사항

  • 반드시 valueserrors를 포함하는 객체를 반환해야 하며, 기본 값은 빈 객체{}다.
  • errors 객체의 키는 폼 필드의 name 값과 일치해야 한다.
export default function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({ resolver })
  /* 중략 */
}

useForm을 호출하는 컴포넌트 내부에서 위에서 선언한 resolver를 props로 전달하여 이용한다.

⚠️ zod 사용 예제

const schema = z.object({
  name: z.string(),
  age: z.number(),
})

type Schema = z.infer<typeof schema>

const App = () => {
  const { register, handleSubmit } = useForm<Schema>({
    resolver: zodResolver(schema),
  })
  /* 중략 */
}

zod에서 따로 제공하는 zodResolver를 이용해 위와 같은 방식으로 이용할 수 있다.

SubmitHandler

서버로 폼 데이터 전송 시, handleSubmit을 이용하는 방법에 대한 코드로 SubmitHandler는 따지자면 import 가능한 타입에 해당한다!

useForm - handleSubmit

handleSubmit

props로 아래 두 가지를 전달받기 때문에, 작성할 때 반드시 내부 props를 작성한다.

  • SubmitHandler (data: Object, e?: Event) => Promise<void>
  • SubmitErrorHandler (errors: Object, e?: Event) => Promise<void>

상세 코드

export default function App() {
  const { register, handleSubmit } = useForm<FormValues>()
  const onSubmit: SubmitHandler<FormValues> = (data) => console.log(data)
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
    {/* 중략 */}
    </form>
  )
}

Control

주로 Controller와 많이 이용되며, React Hook Form에 컴포넌트를 등록하는 메서드를 포함한다.

useForm - control

상세 코드 : 입력 시, 리렌더링시키기

여기서 설명하는 예제는 firstName key에서 입력이 발생하면 이를 html 요소에서 입력값과 동일하게 표시되도록 만드는 코드로 control을 props로 넘겨주어 다른 컴포넌트(자식 컴포넌트로 명명)에서 useForm에 접근이 가능, useWatch를 이용해 변경된 값을 파악하고 이를 변경하도록 작성하였다.

/* 자식 컴포넌트 */
function IsolateReRender({ control }: { control: Control<FormValues> }) {
  const firstName = useWatch({
    control,
    name: "firstName",
    defaultValue: "default",
  })

  return <div>{firstName}</div>
}
/* 
* 부모 컴포넌트
* - 해당 컴포넌트에서 useForm을 호출, control을 통해 자식 컴포넌트가 해당 form에 접근 가능하도록 한다. 
*/

export default function App() {
  const { register, control, handleSubmit } = useForm<FormValues>()
  const onSubmit = handleSubmit((data) => console.log(data))

  return (
    <form onSubmit={onSubmit}>
      <input {...register("firstName")} />
      <input {...register("lastName")} />
      <IsolateReRender control={control} />

      <input type="submit" />
    </form>
  )
}

Advanced

Smart Form Component

아래처럼 props 전달 없이 폼 데이터를 자동으로 수집하는 Form 컴포넌트 생성 방법

import { Form, Input, Select } from "./Components"

export default function App() {
  const onSubmit = (data) => console.log(data)
  
  return (
    <Form onSubmit={onSubmit}>
      <Input name="firstName" />
      <Input name="lastName" />
      <Select name="gender" options={["female", "male", "other"]} />

      <Input type="submit" value="Submit" />
    </Form>
  )
}

Form

defaultValues, onSubmit을 전달받아 useForm을 호출하고 해당 컴포넌트 내에서 Children을 이용해 각각 registerkey를 할당한다.

import { Children, createElement } from "react"
import { useForm } from "react-hook-form"

export default function Form({ defaultValues, children, onSubmit }) {
  const methods = useForm({ defaultValues })
  const { handleSubmit } = methods

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {Children.map(children, (child) => {
        return child.props.name
          ? createElement(child.type, {
              ...{
                ...child.props,
                register: methods.register,
                key: child.props.name,
              },
            })
          : child
      })}
    </form>
  )
}

Input / Select

Form에서 register를 할당했기때문에 해당 컴포넌트들에서 이용하는 것이 가능하다.

export function Input({ register, name, ...rest }) {
  return <input {...register(name)} {...rest} />
}

export function Select({ register, options, name, ...rest }) {
  return (
    <select {...register(name)} {...rest}>
      {options.map((value) => (
        <option key={value} value={value}>
          {value}
        </option>
      ))}
    </select>
  )
}

Connect Form

입력 필드가 깊게 중첩된 컴포넌트 내부에 위치하는 경우, 주로 useFormContext를 사용해 이를 해결하지만 ConnectForm 컴포넌트를 생성, 리액트의 renderProps 패턴을 활용해서 대체할 수도 있다.

import { FormProvider, useForm, useFormContext } from "react-hook-form"
  • ConnectForm ConnectForm을 이용해 자식 컴포넌트에 methods를 일괄적으로 전달할 수 있도록 작성한다.
    export const ConnectForm = ({ children }) => {
      const methods = useFormContext()
      return children(methods)
  • DeepNest 깊게 중첩된 컴포넌트에 해당하며, ConnectForm 내의 Child가 받는 methods 중 register를 전달하는 경우로 작성되었다.
    export const DeepNest = () => (
      <ConnectForm>
        {({ register }) => <input {...register("deepNestedInput")} />}
      </ConnectForm>
    )
  • App : 최상위 부모 컴포넌트 최상위에서는 useForm을 이용해 methods를 return, FormProvider로 이를 제공한다.
    export const App = () => {
      const methods = useForm()
      
      return (
        <FormProvider {...methods}>
          <form>
            <DeepNest />
          </form>
        </FormProvider>
      )
    }

FormProvider Performance : FormProvider 성능 개선

FormProvider는 React의 Context API를 기반으로 구축되었기 때문에 props drilling은 방지할 수 있으나 상태 업데이트 시 트리가 다시 리렌더링 될 수 있다는 문제점이 존재한다.

이 문제점을 해결하기 위한 최적화 코드로, memo를 이용하였다.

import { memo } from "react"
import { useForm, FormProvider, useFormContext } from "react-hook-form"
  • NestedInput memoization을 수행하는 곳으로, isDirty가 수행되는 경우 이외에는 리렌더링을 막는다.
    // we can use React.memo to prevent re-render except isDirty state changed
    const NestedInput = memo(
      ({ register, formState: { isDirty } }) => (
        <div>
          <input {...register("test")} />
          {isDirty && <p>This field is dirty</p>}
        </div>
      ),
      (prevProps, nextProps) =>
        prevProps.formState.isDirty === nextProps.formState.isDirty
    )

아래는 NestedInputContainer와 최상위 컴포넌트 App 코드이다.

export const NestedInputContainer = ({ children }) => {
  const methods = useFormContext()
  return <NestedInput {...methods} />
}

export default function App() {
  const methods = useForm()
  const onSubmit = (data) => console.log(data)
  console.log(methods.formState.isDirty) // make sure formState is read before render to enable the Proxy

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <NestedInputContainer />
        <input type="submit" />
      </form>
    </FormProvider>
  )
}

여기서 console.log(methods.formState.isDirty) 코드가 필요한 이유는 formState 객체가 내부적으로 Proxy를 사용해 성능을 최적화시키기 때문이다.

따라서 formState의 특정 속성(isDirty, isValid 등)이 실제로 읽히기 전까지는 해당 상태를 추적하고 있지 않으므로, 렌더링 전에 formState.isDirty를 읽어주는 것이다.

더 자세한 내용은 공식문서 내 formState의 Rules에서 확인이 가능하다.

Controlled mixed with Uncontrolled Components

React Hook Form은 기본적으로 비제어 컴포넌트를 사용하지만 제어 컴포넌트를 사용할 수 있는 방법이 있다.

제어 컴포넌트는 쉽게 UI 라이브러리(MUI 등) 내부 컴포넌트라 생각하면 된다.

또한 제어 컴포넌트만 사용해야 하는 것이 아니라, 비제어 컴포넌트와도(기본적인 이용 방법) 섞어 사용할 수 있으며 아래 코드는 그 내용에 관한 예제다.

MUI 컴포넌트 React Hook Form과 이용하기

import { Input, Select, MenuItem } from "@material-ui/core"
import { useForm, Controller } from "react-hook-form"

const defaultValues = {
  select: "",
  input: "",
}

아래의 Input, Select, MenuItem은 MUI에서 제공하는 컴포넌트들이다. 딱히 다를 건 없어서 자세한 내용은 패스…

function App() {
  const { handleSubmit, reset, control, register } = useForm({
    defaultValues,
  })
  const onSubmit = (data) => console.log(data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        render={({ field }) => (
          <Select {...field}>
            <MenuItem value={10}>Ten</MenuItem>
            <MenuItem value={20}>Twenty</MenuItem>
          </Select>
        )}
        control={control}
        name="select"
        defaultValue={10}
      />
      <Input {...register("input")} />
      <button type="button" onClick={() => reset({ defaultValues })}>
        Reset
      </button>
      <input type="submit" />
    </form>
  )
}

Transform and Parse

기본적으로 입력값들은 문자열 형식으로 반환되며 valueAsNumbervalueAsDate를 이용해 다르게 처리할 수 있으나 isNaN이나 null 값을 처리하기엔 역부족이므로 custom hook을 이용하여 처리해야 한다.

이런 문제점을 해결하기 위하여 Controller를 이용해 입력 값을 변환하는 방법이다.

  • ControllerPlus 컴포넌트 기본적인 Controller props들을 전달받고, 그 중 transform 함수를 input의 onChange에 적용시키는 새로운 Controller를 생성한다.
    const ControllerPlus = ({ control, transform, name, defaultValue }) => (
      <Controller
        defaultValue={defaultValue}
        control={control}
        name={name}
        render={({ field }) => (
          <input
            onChange={(e) => field.onChange(transform.output(e))}
            value={transform.input(field.value)}
          />
        )}
      />
    )
  • 컴포넌트 사용 예 전달 시, transform을 통해 변환하고자 하는 방법에 대해 작성해 넘긴다.
    ...
    <ControllerPlus
      transform={{
        input: (value) => (isNaN(value) || value === 0 ? "" : value.toString()),
        output: (e) => {
          const output = parseInt(e.target.value, 10)
          return isNaN(output) ? 0 : output
        },
      }}
      control={control}
      name="number"
      defaultValue=""
    />
    ...

끝내면서

숙원 사업(…)인 React Hook Form 공식문서 뜯어보기를 진행했는데, API 부분을 작성하다가 해당 주제로 넘긴 건 백 번 잘한 것 같다. API는 이것저것 하면서 들여다 볼 일이 많았지만 이 부분은 도저히 볼 엄두가 안 났는데, 덕분에 리팩토링 할 때 수월할 것 같다는 생각👍🏻

Advanced 부분 중 두 가지 정도를 제외하고 작성했는데 이 부분은 더 뜯어볼 예정이다…

profile
프론트엔드 일짱되기

0개의 댓글

관련 채용 정보