이번엔 React Hook Form 공식문서에서 제공하는 TS 탭 내 코드 일부(Type 설명 제외)와 Advanced 탭 내 항목 코드를 해체해봤다…
useForm
의 schema validation option에 해당하며, 스키마 검증 라이브러리와 통합하기 위해 사용한다.
이름 | 타입 | 설명 |
---|---|---|
values | object | 폼의 모든 입력값을 포함하는 객체 |
context | object | useForm 설정에서 제공할 수 있는 컨텍스트 객체. |
변경 가능하며, 리렌더링될 때마다 업데이트될 수 있음. | ||
options | { criteriaMode: string, fields: object, names: string[] } | 유효성 검사된 필드 정보, 필드 이름 목록, criteriaMode 설정을 포함하는 옵션 객체 |
type FormValues = {
firstName: string
lastName: string
}
FormValue type 설정. Form에서 등록할 스키마를 미리 정의한다. firstName
과 lastName
을 register의 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
이용 시의 주의사항
- 반드시
values
와errors
를 포함하는 객체를 반환해야 하며, 기본 값은 빈 객체{}
다.errors
객체의 키는 폼 필드의name
값과 일치해야 한다.
export default function App() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver })
/* 중략 */
}
useForm을 호출하는 컴포넌트 내부에서 위에서 선언한 resolver를 props로 전달하여 이용한다.
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
를 이용해 위와 같은 방식으로 이용할 수 있다.
서버로 폼 데이터 전송 시, handleSubmit을 이용하는 방법에 대한 코드로 SubmitHandler
는 따지자면 import 가능한 타입에 해당한다!
props로 아래 두 가지를 전달받기 때문에, 작성할 때 반드시 내부 props를 작성한다.
(data: Object, e?: Event) => Promise<void>
(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>
)
}
주로 Controller와 많이 이용되며, React Hook Form에 컴포넌트를 등록하는 메서드를 포함한다.
여기서 설명하는 예제는 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>
)
}
아래처럼 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>
)
}
defaultValues
, onSubmit
을 전달받아 useForm
을 호출하고 해당 컴포넌트 내에서 Children
을 이용해 각각 register
와 key
를 할당한다.
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>
)
}
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>
)
}
입력 필드가 깊게 중첩된 컴포넌트 내부에 위치하는 경우, 주로 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는 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에서 확인이 가능하다.
React Hook Form은 기본적으로 비제어 컴포넌트를 사용하지만 제어 컴포넌트를 사용할 수 있는 방법이 있다.
제어 컴포넌트는 쉽게 UI 라이브러리(MUI 등) 내부 컴포넌트라 생각하면 된다.
또한 제어 컴포넌트만 사용해야 하는 것이 아니라, 비제어 컴포넌트와도(기본적인 이용 방법) 섞어 사용할 수 있으며 아래 코드는 그 내용에 관한 예제다.
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>
)
}
기본적으로 입력값들은 문자열 형식으로 반환되며 valueAsNumber
나 valueAsDate
를 이용해 다르게 처리할 수 있으나 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)}
/>
)}
/>
)
...
<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 부분 중 두 가지 정도를 제외하고 작성했는데 이 부분은 더 뜯어볼 예정이다…