접근성 (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><inputid="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>{/* 필드에 에러가 있음을 알리기 위해 aria-invalid 속성을 사용 */}<inputid="name"aria-invalid={errors.name ? "true" : "false"}{...register("name", { required: true, maxLength: 30 })}/>{/* 에러 메시지를 제공하기 위해 role="alert" 속성을 사용. */}{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>)}
이렇게 개선하면 스크린 리더가 다음과 같이 읽어줍니다: “이름, 편집, 잘못된 입력, 이 필드는 필수입니다.”
단계별 폼(Wizard Form) / 퍼널
여러 페이지나 섹션에 걸쳐 사용자 정보를 수집하는 것은 매우 일반적인 패턴입니다. 각 페이지나 섹션을 오가며 유저가 입력한 값을 유지하려면 상태 관리 라이브러리를 사용하는 것을 권장합니다. 이 예제에서는 little state machine을 상태 관리 라이브러리로 사용합니다(만약 redux에 더 친숙하다면, 이를 redux로 대체할 수 있습니다).
1단계: 라우트(routes)와 스토어(store)를 설정합니다.
import { BrowserRouter as Router, Route } from "react-router-dom"import { StateMachineProvider, 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 (<StateMachineProvider><Router><Route exact path="/" component={Step1} /><Route path="/step2" component={Step2} /><Route path="/result" component={Result} /></Router></StateMachineProvider>)}
2단계: 페이지들을 만들고, 데이터를 수집하고 스토어(store)에 저장한 뒤, 다음 폼/페이지로 이동합니다.
import { useForm } from "react-hook-form"import { withRouter } from "react-router-dom"import { useStateMachine } from "little-state-machine"import updateAction from "./updateAction"const Step1 = (props) => {const { register, handleSubmit } = useForm()const { actions } = useStateMachine({ updateAction })const onSubmit = (data) => {actions.updateAction(data)props.history.push("./step2")}return (<form onSubmit={handleSubmit(onSubmit)}><input {...register("firstName")} /><input {...register("lastName")} /><input type="submit" /></form>)}export default withRouter(Step1)
3단계: 스토어(store)에 쌓인 모든 데이터를 최종 제출하거나, 결과를 화면에 표시합니다.
import { useStateMachine } from "little-state-machine"import updateAction from "./updateAction"const Result = (props) => {const { state } = useStateMachine(updateAction)return <pre>{JSON.stringify(state, null, 2)}</pre>}
위와 같은 흐름을 따르면, 여러 페이지에 걸쳐 사용자 입력 데이터를 모아 처리하는 단계별 폼(Wizard Form)/퍼널을 구현할 수 있습니다.
똑똑한 폼 컴포넌트
여기서의 아이디어는 입력 컴포넌트를 이용하여 손쉽게 폼을 구성하는 것입니다. 폼 데이터를 자동으로 수집하는 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
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 } = methodsreturn (<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
Input
/Select
컴포넌트는 react-hook-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>)}
Form
컴포넌트가 react-hook-form
의 속성
(props)을 자식 컴포넌트에게 주입해주기 때문에 앱에서 복잡한 폼도 손쉽게 생성하고 조합할 수 있습니다.
에러 메세지
에러 메세지는 사용자 입력에 문제가 있을 때 사용자에게 시각적 피드백을 제공합니다. React Hook Form은 errors
객체를 제공하여 에러 정보를 손쉽게 가져올 수 있도록 합니다. 화면에 에러를 더 깔끔하게 표시하는 방법에는 여러 가지가 있습니다.
-
Register
유효성 검증 규칙 객체의
message
속성을 통해, 아래와 같이register
에 바로 에러 메시지를 전달할 수 있습니다.:<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
컴포넌트를 만들고 리액트의 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 위에 구축되었습니다. 이는 데이터가 컴포넌트 트리를 따라 전달될 때 매 단계마다 수동으로 props를 내려줘야 한다는 문제를 해결합니다. 다만, React Hook Form이 상태 업데이트를 트리거할 때 컴포넌트 트리가 다시 렌더링되는 부작용이 생깁니다. 필요하다면 아래 예제를 통해 앱을 최적화할 수 있습니다.
참고: React Hook Form의 Devtools을 FormProvider와 함께 사용하면 일부 상황에서 성능 문제가 발생할 수 있습니다. 본격적인 최적화에 들어가기 전, 이 병목 현상을 먼저 고려하세요.
import { memo } from "react"import { useForm, FormProvider, useFormContext } from "react-hook-form"// isDirty 상태가 변경될 때를 제외하고는 리렌더되지 않도록 React.memo를 사용할 수 있습니다.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) // Proxy를 활성화하려면 렌더링 전에 formState를 반드시 읽어야 합니다.return (<FormProvider {...methods}><form onSubmit={methods.handleSubmit(onSubmit)}><NestedInputContainer /><input type="submit" /></form></FormProvider>)}
제어 컴포넌트와 비제어 컴포넌트의 혼합 사용
React Hook Form은 비제어 컴포넌트를 기본으로 하지만, 제어 컴포넌트와도 완벽히 호환합니다. MUI와 Antd 같은 대부분의 UI 라이브러리는 제어 컴포넌트만 지원하도록 설계되어 있지만, React Hook Form을 사용하면 제어 컴포넌트의 리렌더링도 최적화할 수 있습니다. 아래 예제는 제어 컴포넌트와 비제어 컴포넌트를 유효성 검증과 함께 결합한 코드입니다.
import { Input, Select, MenuItem } from "@material-ui/core"import { useForm, Controller } from "react-hook-form"const defaultValues = {select: "",input: "",}function App() {const { handleSubmit, reset, control, register } = useForm({defaultValues,})const onSubmit = (data) => console.log(data)return (<form onSubmit={handleSubmit(onSubmit)}><Controllerrender={({ 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>)}
Resolver를 활용한 커스텀 훅
커스텀 훅을 resolver로 구현할 수 있습니다. 이렇게 하면 커스텀 훅은 yup/Joi/Superstruct와 같은 유효성 검증 메서드와 쉽게 통합할 수 있으며, 유효성 검증 resolver 내부에서 활용할 수 있습니다.
- 메모이제이션된 유효성 검증 스키마를 정의합니다.(의존성이 없다면 컴포넌트 외부에 정의해도 됩니다.)
- 유효성 검증 스키마를 전달하여 커스텀 훅을 사용합니다.
- 생성된 유효성 검증 resolver를
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>)}
가상화된(virtualized) 리스트 다루기
데이터 테이블이 있는 시나리오를 상상해보세요. 이 테이블에는 수백 또는 수천 개의 행이 있을 수 있으며, 각 행에는 입력 필드가 들어 있습니다. 일반적으로 뷰포트에 보이는 항목만 렌더링하는 방식을 사용하지만, 이 경우 항목이 뷰포트 밖으로 나가면 DOM에서 제거되었다가 다시 추가되면서, 다시 보이는 순간 입력값이 기본값으로 리셋되는 문제가 발생할 수 있습니다.
아래 예제는 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 <input {...register(`${index}.quantity`)} />})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 }) => (<Listheight={height}itemCount={items.length}itemSize={() => 100}width={width}itemData={items}>{WindowedRow}</List>)}</AutoSizer></FormProvider><button type="submit">Submit</button></form>)}
폼 테스트
테스트는 코드에 버그나 실수가 없도록 방지해 주기 때문에 매우 중요합니다. 또한 테스트는 코드베이스를 리팩토링할 때 안전성을 보장합니다.
간단하고 사용자 행동 중심의 테스트를 작성할 수 있는 testing-library 사용을 권장합니다.
1단계: 테스트 환경 설정
React Hook Form은 입력 필드를 감지하고 DOM에서 언마운트되는 것을 확인하기 위해 MutationObserver
를 사용하므로, 최신 버전의 jest
와 함께 @testing-library/jest-dom을 설치하세요.
참고: React Native를 사용 중이라면, @testing-library/jest-dom을 설치할 필요가 없습니다.
npm install -D @testing-library/jest-dom
@testing-library/jest-dom를 import할 setup.js
를 생성하세요.
import "@testing-library/jest-dom"
참고: React Native를 사용 중이라면, setup.js을 생성하고, window
객체를 정의한 뒤, 아래 내용을 setup 파일에 추가해야 합니다:
global.window = {}global.window = global
마지막으로,jest.config.js
에서 setup.js
를 포함하도록 업데이트해야 합니다.
module.exports = {setupFilesAfterEnv: ["<rootDir>/setup.js"], // 또는 TypeScript App을 위한 .ts// ...다른 settings}
추가로, 테스트 작성 시 모범 사례를 준수하고 자주 발생하는 실수를 방지하기 위해 eslint-plugin-testing-library와 eslint-plugin-jest-dom 플러그인을 설정할 수 있습니다.
Step 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><inputid="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><inputid="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단계: 테스트 작성하기.
다음 기준을 테스트로 커버합니다:
-
제출 실패 테스트
handleSubmit
메서드는 비동기적으로 실행되기 때문에,waitFor
유틸과find*
쿼리를 사용해 제출 피드백을 확인합니다. -
유효성 검증과 관련있는 각 입력 필드 테스트
사용자가 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))expect(mockLogin).toBeCalledWith("test@mail.com", "password")expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("")expect(screen.getByLabelText("password")).toHaveValue("")})
테스트 중 act 경고 해결하기
react-hook-form을 사용하는 컴포넌트를 테스트할 때, 해당 컴포넌트에 비동기 코드를 작성하지 않아도 다음과 같은 경고가 발생할 수 있습니다:
Warning: An update to MyComponent inside a test was not wrapped in 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()// UI가 비동기 동작이 완료될 때까지 대기했으므로,// 이제 `get*` 쿼리를 사용해 계속해서 검증할 수 있습니다.expect(screen.getByRole("textbox")).toBeInTheDocument()})
변환 및 파싱
네이티브 input
요소는 valueAsNumber
나 valueAsDate
를 사용하지 않으면 항상 값을 string
형식으로 반환합니다. 자세한 내용은 이 부분을 참고하세요. 하지만 이 방식만으로는 완벽하지 않습니다. 여전히 isNaN
이나 null
값을 직접 처리해야 하기 때문입니다. 따라서 변환 로직은 커스텀 훅 수준에서 처리하는 것이 좋습니다.
아래 예제에서는, Controller
를 사용하여 입력값의 변환과 출력 기능을 함께 구현했습니다. 커스텀 register
를 사용해도 유사한 결과를 얻을 수 있습니다.
import { Controller } from "react-hook-form"const ControllerPlus = ({ control, transform, name, defaultValue }) => (<ControllerdefaultValue={defaultValue}control={control}name={name}render={({ field }) => (<inputonChange={(e) => field.onChange(transform.output(e))}value={transform.input(field.value)}/>)}/>)// 사용 예시는 아래와 같음:<ControllerPlustransform={{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이 유용하다고 생각하신다면, 스타를 눌러 지원해 주시길 부탁드립니다.