고급 사용법
접근성(A11y)
React Hook Form은 자체 규칙으로 입력을 검증할 수 있는 네이티브 폼 검증을 지원합니다. 대부분 사용자 정의 디자인과 레이아웃으로 폼을 만들어야 하므로 접근성(A11y)을 보장할 책임이 있습니다.
다음 코드 예제의 검증은 의도대로 동작하지만 접근성을 개선할 수 있습니다.
import { useForm } from "react-hook-form"
export default function App() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm()
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="name">Name</label>
<input
id="name"
{...register("name", { required: true, maxLength: 30 })}
/>
{errors.name && errors.name.type === "required" && (
<span>This is required</span>
)}
{errors.name && errors.name.type === "maxLength" && (
<span>Max length exceeded</span>
)}
<input type="submit" />
</form>
)
}
다음 코드 예제는 ARIA를 활용하여 개선한 버전입니다.
import { useForm } from "react-hook-form"
export default function App() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm()
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="name">Name</label>
{/* use aria-invalid to indicate that the field contains an error */}
<input
id="name"
aria-invalid={errors.name ? "true" : "false"}
{...register("name", { required: true, maxLength: 30 })}
/>
{/* use role="alert" to announce the error message */}
{errors.name && errors.name.type === "required" && (
<span role="alert">This is required</span>
)}
{errors.name && errors.name.type === "maxLength" && (
<span role="alert">Max length exceeded</span>
)}
<input type="submit" />
</form>
)
}
개선 후 스크린 리더는 _“이름, 편집, 잘못된 항목, 필수 항목입니다.”_라고 읽습니다.
마법사 폼 / 퍼널
여러 페이지와 섹션에 걸쳐 사용자 정보를 수집하는 경우가 많습니다. 여러 페이지나 섹션에서 입력한 값을 저장하려면 상태 관리 라이브러리를 사용하는 것이 좋습니다. 이 예제에서는 little state machine을 상태 관리 라이브러리로 사용합니다(redux가 더 익숙하다면 대체할 수 있습니다).
1단계: route와 store를 설정합니다.
import { BrowserRouter, Routes, Route } from "react-router-dom"
import { createStore } from "little-state-machine"
import Step1 from "./Step1"
import Step2 from "./Step2"
import Result from "./Result"
createStore({
data: {
firstName: "",
lastName: "",
},
})
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Step1 />} />
<Route path="/step2" element={<Step2 />} />
<Route path="/result" element={<Result />} />
</Routes>
</BrowserRouter>
)
}
2단계: 페이지를 만들고 데이터를 수집하여 store에 제출한 다음 다음 폼/페이지로 이동합니다.
import { useForm } from "react-hook-form"
import { useNavigate } from "react-router-dom"
import { useStateMachine } from "little-state-machine"
import updateAction from "./updateAction"
export default function Step1() {
const { register, handleSubmit } = useForm()
const { actions } = useStateMachine({ actions: { updateAction } })
const navigate = useNavigate()
const onSubmit = (data) => {
actions.updateAction(data)
navigate("/step2")
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("firstName")} />
<input {...register("lastName")} />
<input type="submit" />
</form>
)
}
3단계: store의 모든 데이터로 최종 제출하거나 결과 데이터를 표시합니다.
import { useStateMachine } from "little-state-machine"
export default function Result() {
const { state } = useStateMachine()
return <pre>{JSON.stringify(state, null, 2)}</pre>
}
위 패턴을 따르면 여러 페이지에서 사용자 입력 데이터를 수집하는 마법사 폼/퍼널을 만들 수 있습니다.
스마트 폼 컴포넌트
입력 컴포넌트를 사용하여 폼을 쉽게 구성하는 방법입니다. 폼 데이터를 자동으로 수집하는 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"]} />
{/* not routed through the custom Input — it has no `name`, so `Form` won't inject `register` for it */}
<input type="submit" value="Submit" />
</Form>
)
}
각 컴포넌트의 내부를 살펴보겠습니다.
</> Form
Form 컴포넌트는 모든 react-hook-form 메서드를 자식 컴포넌트에 주입합니다.
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
이 입력 컴포넌트는 자신을 react-hook-form에 등록합니다.
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>
)
}
Form 컴포넌트가 react-hook-form의 props를 자식 컴포넌트에 주입하므로 앱에서 복잡한 폼을 쉽게 만들고 구성할 수 있습니다.
오류 메시지
오류 메시지는 사용자의 입력에 문제가 있을 때 제공하는 시각적 피드백입니다. React Hook Form은 오류를 쉽게 가져올 수 있도록 errors 객체를 제공합니다. 화면에서 오류를 더 잘 표시하는 방법은 여러 가지입니다.
-
Register
오류 메시지를
register에 전달할 때 다음과 같이 검증 규칙 객체의message속성을 사용할 수 있습니다.<input {...register('test', { maxLength: { value: 2, message: "error message" } })} /> -
Optional Chaining
?.optional chaining 연산자를 사용하면errors객체를 읽을 때null이나undefined로 인해 또 다른 오류가 발생할 걱정이 없습니다.errors?.firstName?.message -
Lodash
get프로젝트에서 lodash를 사용한다면 lodash의 get 함수를 활용할 수 있습니다. 예:
get(errors, 'firstName.message')
폼 연결
폼을 만들 때 입력이 깊게 중첩된 컴포넌트 트리 안에 있는 경우 FormContext가 유용합니다. ConnectForm 컴포넌트를 만들고 React의 renderProps를 활용하면 개발자 경험을 더 개선할 수 있습니다. 입력을 React Hook Form에 훨씬 쉽게 연결할 수 있다는 장점이 있습니다.
import { FormProvider, useForm, useFormContext } from "react-hook-form"
export const ConnectForm = ({ children }) => {
const methods = useFormContext()
return children(methods)
}
export const DeepNest = () => (
<ConnectForm>
{({ register }) => <input {...register("deepNestedInput")} />}
</ConnectForm>
)
export const App = () => {
const methods = useForm()
return (
<FormProvider {...methods}>
<form>
<DeepNest />
</form>
</FormProvider>
)
}
FormProvider 성능
React Hook Form의 FormProvider는 React Context API를 기반으로 합니다. 모든 수준에서 prop을 수동으로 내려보내지 않고 컴포넌트 트리를 통해 데이터를 전달하는 문제를 해결합니다. React Hook Form이 상태 업데이트를 실행할 때 컴포넌트 트리도 리렌더링되지만, 필요한 경우 아래 예제처럼 애플리케이션을 최적화할 수 있습니다.
참고: React Hook Form의 Devtools를 FormProvider와 함께 사용하면 일부 상황에서 성능 문제가 발생할 수 있습니다. 성능 최적화를 깊이 파고들기 전에 이 병목부터 고려하세요.
import { memo } from "react"
import { useForm, FormProvider, useFormContext } from "react-hook-form"
// we can use memo to prevent re-renders except when the isDirty state changes
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
)
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 rendering to enable the Proxy
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<NestedInputContainer />
<input type="submit" />
</form>
</FormProvider>
)
}
제어 컴포넌트와 비제어 컴포넌트 혼합
React Hook Form은 비제어 컴포넌트를 지향하지만 제어 컴포넌트와도 호환됩니다. MUI, Antd 같은 UI 라이브러리의 일부 컴포넌트(예: Select, Checkbox)는 네이티브 input ref를 노출하지 않으므로 Controller로 감싸야 합니다. Input 같은 단순한 컴포넌트는 ref를 전달하므로 {...register}와 직접 사용할 수 있습니다. React Hook Form은 제어 컴포넌트의 리렌더링도 최적화합니다. 다음은 두 패턴을 검증과 함께 사용하는 예제입니다.
import { Input, Select, MenuItem } from "@mui/material"
import { useForm, Controller } from "react-hook-form"
const defaultValues = {
select: 10,
input: "",
}
function App() {
const { handleSubmit, reset, control, register } = useForm({
defaultValues,
})
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Controller's own defaultValue is ignored here — useForm's defaultValues takes precedence for the same field */}
<Controller
render={({ field }) => (
<Select {...field}>
<MenuItem value={10}>Ten</MenuItem>
<MenuItem value={20}>Twenty</MenuItem>
</Select>
)}
control={control}
name="select"
/>
<Input {...register("input")} />
<button type="button" onClick={() => reset({ ...defaultValues })}>
Reset
</button>
<input type="submit" />
</form>
)
}
import { useEffect } from "react"
import { Input, Select, MenuItem } from "@mui/material"
import { useForm } from "react-hook-form"
const defaultValues = {
select: "",
input: "",
}
function App() {
const { register, handleSubmit, setValue, reset, watch } = useForm({
defaultValues,
})
const selectValue = watch("select")
const onSubmit = (data) => console.log(data)
useEffect(() => {
register("select")
}, [register])
const handleChange = (e) => setValue("select", e.target.value)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Select value={selectValue} onChange={handleChange}>
<MenuItem value={10}>Ten</MenuItem>
<MenuItem value={20}>Twenty</MenuItem>
</Select>
<Input {...register("input")} />
<button type="button" onClick={() => reset({ ...defaultValues })}>
Reset
</button>
<input type="submit" />
</form>
)
}
리졸버를 사용하는 커스텀 훅
리졸버 역할을 하는 커스텀 훅을 만들 수 있습니다. 커스텀 훅은 Yup/Joi/Superstruct를 검증 리졸버 안에서 사용할 검증 메서드로 쉽게 통합할 수 있습니다.
- 메모이제이션된 검증 스키마를 정의합니다(의존성이 없다면 컴포넌트 외부에서 정의).
- 검증 스키마를 전달하여 커스텀 훅을 사용합니다.
- 검증 리졸버를 useForm 훅에 전달합니다.
import { useCallback } from "react"
import { useForm } from "react-hook-form"
import * as yup from "yup"
const useYupValidationResolver = (validationSchema) =>
useCallback(
async (data) => {
try {
const values = await validationSchema.validate(data, {
abortEarly: false,
})
return {
values,
errors: {},
}
} catch (errors) {
return {
values: {},
errors: errors.inner.reduce(
(allErrors, currentError) => ({
...allErrors,
[currentError.path]: {
type: currentError.type ?? "validation",
message: currentError.message,
},
}),
{}
),
}
}
},
[validationSchema]
)
const validationSchema = yup.object({
firstName: yup.string().required("Required"),
lastName: yup.string().required("Required"),
})
export default function App() {
const resolver = useYupValidationResolver(validationSchema)
const { handleSubmit, register } = useForm({ resolver })
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("firstName")} />
<input {...register("lastName")} />
<input type="submit" />
</form>
)
}
가상화 목록 사용
수백 또는 수천 개의 행이 있고 각 행에 입력이 있는 데이터 표를 생각해 보세요. 일반적으로 viewport 안의 항목만 렌더링하지만, 화면 밖으로 나간 항목이 DOM에서 제거된 후 다시 추가되면서 문제가 발생합니다. 항목이 viewport에 다시 들어오면 기본값으로 재설정됩니다.
아래는 react-window를 사용한 예제입니다.
import { memo } from "react"
import { FormProvider, useForm, useFormContext } from "react-hook-form"
import { VariableSizeList as List } from "react-window"
import AutoSizer from "react-virtualized-auto-sizer"
const items = Array.from(Array(1000).keys()).map((i) => ({
title: `List ${i}`,
quantity: Math.floor(Math.random() * 10),
}))
const WindowedRow = memo(({ index, style, data }) => {
const { register } = useFormContext()
return (
<div style={style}>
<label>{data[index].title}</label>
<input {...register(`${index}.quantity`)} />
</div>
)
})
export const App = () => {
const onSubmit = (data) => console.log(data)
const methods = useForm({ defaultValues: items })
return (
<form onSubmit={methods.handleSubmit(onSubmit)}>
<FormProvider {...methods}>
<AutoSizer>
{({ height, width }) => (
<List
height={height}
itemCount={items.length}
itemSize={() => 100}
width={width}
itemData={items}
>
{WindowedRow}
</List>
)}
</AutoSizer>
</FormProvider>
<button type="submit">Submit</button>
</form>
)
}
import { FixedSizeList } from "react-window"
import { Controller, useFieldArray, useForm } from "react-hook-form"
const items = Array.from(Array(1000).keys()).map((i) => ({
title: `List ${i}`,
quantity: Math.floor(Math.random() * 10),
}))
function App() {
const { control, getValues, handleSubmit } = useForm({
defaultValues: {
test: items,
},
})
const { fields } = useFieldArray({ control, name: "test" })
const onSubmit = (data) => console.log(data)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FixedSizeList
width={400}
height={500}
itemSize={40}
itemCount={fields.length}
itemData={fields}
itemKey={(i) => fields[i].id}
>
{({ style, index, data }) => {
const defaultValue =
getValues()["test"][index].quantity ?? data[index].quantity
return (
<div style={style}>
<Controller
render={({ field }) => <input {...field} />}
name={`test[${index}].quantity`}
defaultValue={defaultValue}
control={control}
/>
</div>
)
}}
</FixedSizeList>
<button type="submit">Submit</button>
</form>
)
}
폼 테스트
테스트는 버그와 실수를 방지하며 리팩터링할 때 코드 안전성을 보장하므로 중요합니다.
간단하며 사용자 행동에 더 집중한 테스트를 작성할 수 있는 testing-library를 사용하는 것이 좋습니다.
1단계: 테스트 환경을 설정합니다.
최신 버전의 jest와 @testing-library/jest-dom을 설치합니다. react-hook-form은 MutationObserver를 사용하여 입력을 감지하고 DOM에서 언마운트합니다.
참고: React Native를 사용한다면 @testing-library/jest-dom을 설치할 필요가 없습니다.
npm install -D @testing-library/jest-dom
testing-library/jest-dom을 가져오는 setup.js를 생성합니다.
import "@testing-library/jest-dom"
참고: React Native를 사용한다면 setup.js를 만들고 window 객체를 정의한 다음 setup 파일에 다음 줄을 포함해야 합니다.
global.window = {}
global.window = global
마지막으로 setup.js 파일이 포함되도록 jest.config.js를 업데이트해야 합니다.
module.exports = {
setupFilesAfterEnv: ["<rootDir>/setup.js"], // or .ts for TypeScript App
// ...other settings
}
추가로 eslint-plugin-testing-library와 eslint-plugin-jest-dom을 설정하여 모범 사례를 따르고 테스트 작성 시 흔한 실수를 미리 방지할 수 있습니다.
2단계: 로그인 폼을 만듭니다.
role 속성을 적절하게 설정했습니다. 이러한 속성은 테스트를 작성하고 접근성을 개선할 때 유용합니다. 자세한 내용은 testing-library 문서를 참고하세요.
import { useForm } from "react-hook-form"
export default function App({ login }) {
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm()
const onSubmit = async (data) => {
await login(data.email, data.password)
reset()
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="email">email</label>
<input
id="email"
{...register("email", {
required: "required",
pattern: {
value: /\S+@\S+\.\S+/,
message: "Entered value does not match email format",
},
})}
type="email"
/>
{errors.email && <span role="alert">{errors.email.message}</span>}
<label htmlFor="password">password</label>
<input
id="password"
{...register("password", {
required: "required",
minLength: {
value: 5,
message: "min length is 5",
},
})}
type="password"
/>
{errors.password && <span role="alert">{errors.password.message}</span>}
<button type="submit">SUBMIT</button>
</form>
)
}
3단계: 테스트를 작성합니다.
테스트에서는 다음 항목을 다룹니다.
-
제출 실패를 테스트합니다.
waitFor유틸리티와find*쿼리를 사용하여 제출 피드백을 감지합니다.handleSubmit메서드가 비동기로 실행되기 때문입니다. -
각 입력에 연결된 검증을 테스트합니다.
사용자가 UI 컴포넌트를 인식하는 방식과 같으므로 여러 요소를 쿼리할 때
*ByRole메서드를 사용합니다. -
제출 성공을 테스트합니다.
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import App from "./App"
const mockLogin = jest.fn((email, password) => {
return Promise.resolve({ email, password })
})
it("should display required error when value is invalid", async () => {
render(<App login={mockLogin} />)
fireEvent.submit(screen.getByRole("button"))
expect(await screen.findAllByRole("alert")).toHaveLength(2)
expect(mockLogin).not.toBeCalled()
})
it("should display matching error when email is invalid", async () => {
render(<App login={mockLogin} />)
fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {
target: {
value: "test",
},
})
fireEvent.input(screen.getByLabelText("password"), {
target: {
value: "password",
},
})
fireEvent.submit(screen.getByRole("button"))
expect(await screen.findAllByRole("alert")).toHaveLength(1)
expect(mockLogin).not.toBeCalled()
expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("test")
expect(screen.getByLabelText("password")).toHaveValue("password")
})
it("should display min length error when password is invalid", async () => {
render(<App login={mockLogin} />)
fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {
target: {
value: "test@mail.com",
},
})
fireEvent.input(screen.getByLabelText("password"), {
target: {
value: "pass",
},
})
fireEvent.submit(screen.getByRole("button"))
expect(await screen.findAllByRole("alert")).toHaveLength(1)
expect(mockLogin).not.toBeCalled()
expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue(
"test@mail.com"
)
expect(screen.getByLabelText("password")).toHaveValue("pass")
})
it("should not display error when value is valid", async () => {
render(<App login={mockLogin} />)
fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {
target: {
value: "test@mail.com",
},
})
fireEvent.input(screen.getByLabelText("password"), {
target: {
value: "password",
},
})
fireEvent.submit(screen.getByRole("button"))
await waitFor(() => {
expect(screen.queryAllByRole("alert")).toHaveLength(0)
// reset() fires after the async login resolves and triggers a re-render;
// all post-reset assertions must be inside waitFor so they retry until
// the DOM reflects the new state.
expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("")
expect(screen.getByLabelText("password")).toHaveValue("")
})
expect(mockLogin).toBeCalledWith("test@mail.com", "password")
})
테스트 중 act 경고 해결
react-hook-form을 사용하는 컴포넌트를 테스트하면 해당 컴포넌트에 비동기 코드를 작성하지 않았더라도 다음과 같은 경고가 발생할 수 있습니다.
경고: 테스트 중 MyComponent 업데이트가 act(...)로 래핑되지 않았습니다.
import { useForm } from "react-hook-form"
export default function App() {
const { register, handleSubmit } = useForm({
mode: "onChange",
})
const onSubmit = (data) => {}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("answer", {
required: true,
})}
/>
<button type="submit">SUBMIT</button>
</form>
)
}
import { render, screen } from "@testing-library/react"
import App from "./App"
it("should have a submit button", () => {
render(<App />)
expect(screen.getByText("SUBMIT")).toBeInTheDocument()
})
이 예제에는 명백한 비동기 코드가 없는 간단한 폼이 있으며 테스트는 컴포넌트를 렌더링하고 버튼이 있는지만 확인합니다. 그런데도 업데이트가 act()로 래핑되지 않았다는 경고가 기록됩니다.
react-hook-form이 내부적으로 비동기 검증 핸들러를 사용하기 때문입니다. formState를 계산하려면 먼저 폼을 검증해야 하며 이 작업은 비동기로 수행되어 추가 렌더링을 일으킵니다. 이 업데이트는 테스트 함수가 반환된 후 발생하므로 경고가 표시됩니다.
이 문제를 해결하려면 find* 쿼리를 사용하여 UI의 요소가 나타날 때까지 기다립니다. render() 호출을 act()로 래핑하면 안 됩니다. 불필요하게 act로 래핑하는 문제에 대해 자세히 알아보세요.
import { render, screen } from "@testing-library/react"
import App from "./App"
it("should have a submit button", async () => {
render(<App />)
expect(await screen.findByText("SUBMIT")).toBeInTheDocument()
// Now that the UI was awaited until the async behavior was complete,
// you can keep asserting with `get*` queries.
expect(screen.getByRole("textbox")).toBeInTheDocument()
})
변환과 파싱
네이티브 입력은 값을 string 형식으로 반환하며 valueAsNumber 또는 valueAsDate를 사용하면 달라집니다. 자세한 내용은 이 섹션을 참고하세요. 하지만 여전히 NaN이나 null 값을 처리해야 하므로 완벽하지 않습니다. 따라서 커스텀 훅 수준에서 변환을 처리하는 것이 좋습니다. 다음 예제에서는 Controller를 사용하여 입력과 출력 변환을 처리합니다. 사용자 정의 register로도 비슷한 결과를 얻을 수 있습니다.
중요: field를 입력에 spread한 다음 onChange/value를 재정의하여 onBlur, name, ref가 계속 전달되게 합니다. ref가 없으면 RHF가 검증 오류 발생 시 입력에 포커스할 수 없고, onBlur가 없으면 isTouched가 업데이트되지 않습니다.
import { Controller } from "react-hook-form"
const ControllerPlus = ({ control, transform, name, defaultValue }) => (
<Controller
defaultValue={defaultValue}
control={control}
name={name}
render={({ field }) => (
<input
{...field}
onChange={(e) => field.onChange(transform.output(e))}
value={transform.input(field.value)}
/>
)}
/>
)
// usage below:
<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=""
/>
Server Actions / useActionState
Next.js Server Actions와 React의 useActionState는 잘 어울리지만 useEffect로 둘 사이의 상태를 동기화하면서 React Hook Form과 결합하면 복잡해 보일 수 있습니다. 그럴 필요는 없습니다. useActionState는 일반 dispatch 함수를 반환하므로 클라이언트 측 검증을 통과한 뒤 handleSubmit 안에서 직접 호출할 수 있습니다.
actions.js: 이전 상태와 검증된 폼 데이터를 받는 Server Action입니다.
"use server"
export async function updateProfile(previousState, data) {
try {
await db.user.update({ data })
return { success: true }
} catch {
return { success: false, error: "Something went wrong. Please try again." }
}
}
profile-form.jsx: handleSubmit이 먼저 검증을 실행한 다음 이미 파싱된 데이터로 action dispatcher(submitAction)를 호출합니다. 따라서 서버에서 FormData를 다시 파싱하거나 useEffect로 상태를 연결할 필요가 없습니다.
"use client"
import { useForm } from "react-hook-form"
import { useActionState } from "react"
import { updateProfile } from "./actions"
export default function ProfileForm() {
const [state, submitAction, isPending] = useActionState(updateProfile, null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm()
return (
<form onSubmit={handleSubmit((data) => submitAction(data))}>
<input {...register("name", { required: "Name is required" })} />
{errors.name && <span role="alert">{errors.name.message}</span>}
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</button>
{state?.success && <p>Profile updated.</p>}
{state?.error && <p role="alert">{state.error}</p>}
</form>
)
}
isPending과 state는 useActionState에서 직접 가져와 위 JSX에서 바로 읽으므로 동기화할 것이 없습니다. isPending은 수동 로딩 useState를 대체하고 state.success / state.error는 수동 결과 useState를 대체합니다. Server Action은 handleSubmit이 폼의 유효성을 확인한 뒤에만 실행됩니다.
서버가 필드 수준 오류(예: "이미 사용 중인 이메일")를 보고해야 한다면 setError를 사용하여 React Hook Form의 오류 상태에 매핑합니다. 이는 useEffect를 적절하게 사용하는 경우입니다. 렌더링 시점 상태의 부재를 우회하는 대신 외부 상태 소스(action이 반환한 state)를 폼에 동기화하기 때문입니다.
useEffect(() => {
if (state?.fieldErrors) {
for (const [name, message] of Object.entries(state.fieldErrors)) {
setError(name, { type: "server", message })
}
}
}, [state, setError])
useActionState의 pending/결과 상태가 전혀 필요 없다면 React Hook Form의 Form 컴포넌트가 action prop을 통해 Server Action으로 직접 제출하고 점진적 향상도 처리할 수 있습니다.
불투명 타입 등록
폼에는 단순한 문자열과 숫자보다 풍부한 값을 저장하는 경우가 많습니다. 예를 들어 Day.js의 Dayjs를 사용하는 날짜 선택기, Decimal.js의 Decimal을 사용하는 가격 필드, Luxon의 DateTime을 사용하는 범위가 있습니다. React Hook Form의 타입 도우미(Path, DeepPartial, FieldErrors 등)는 필드 경로 추론을 구성하기 위해 TFieldValues의 모든 속성을 순회합니다. 기본적으로 내부 속성, getter, 메서드를 포함하여 이러한 클래스 안까지 재귀적으로 순회합니다.
작은 폼에서는 문제가 되지 않습니다. 하지만 큰 폼이나 여러 단계로 중첩된 폼에서는 추가 재귀 비용으로 인해 편집기의 타입 검사가 느려질 수 있으며, 최악의 경우 TypeScript의 "Type instantiation is excessively deep and possibly infinite" 진단이 발생할 수 있습니다.
import { useForm } from "react-hook-form"
import type { Dayjs } from "dayjs"
type BookingForm = {
guest: {
name: string
address: {
line1: string
line2: string
city: string
}
}
stay: {
checkIn: Dayjs
checkOut: Dayjs
}
}
// Without registering Dayjs, Path<BookingForm> recurses into every
// property Dayjs exposes (its internal $d, $y, $M, ..., and every
// method) on top of the form's own fields.
const { register } = useForm<BookingForm>()
Dayjs를 불투명 타입으로 한 번 등록합니다. 프로그램에 포함된 .d.ts 파일(또는 모듈 확장이 닿을 수 있는 파일)에 등록하면 됩니다.
import type { Dayjs } from "dayjs"
declare module "react-hook-form" {
interface OpaqueTypes {
dayjs: Dayjs
}
}
그러면 stay.checkIn과 stay.checkOut은 순회할 객체가 아니라 이미 Date가 처리되는 것처럼 하나의 leaf 값으로 처리됩니다.
const { register, control } = useForm<BookingForm>()
// "stay.checkIn" and "stay.checkOut" are valid, fully-inferred field
// paths; TypeScript no longer descends into Dayjs's own properties
// to compute them.
register("stay.checkIn")
register("guest.address.city")
등록은 프로젝트 전역에 적용됩니다(OpaqueTypes의 선언 병합은 react-hook-form 타입을 사용하는 모든 곳에 적용). 따라서 여러 폼에서 Dayjs, Decimal 또는 비슷한 풍부한 값 타입을 사용하더라도 일반적으로 다른 전역 타입 선언과 함께 한 번만 등록하면 됩니다.
타입 정의와 자세한 내용은 OpaqueTypes TypeScript 레퍼런스를 참고하세요.