뽀믹(formik) 뽀개기 (feat. Yup 라이브러리)

💛 nalsae·2024년 3월 5일
3
post-thumbnail

 React에서 Form 관련 요소를 다루는 방식은 크게 제어 컴포넌트와 비제어 컴포넌트로 구분할 수 있다. 간단하게만 설명하면 제어 컴포넌트 방식은 useState 훅을 사용하여 사용자가 입력하는 값을 상태로 저장하는, 즉 React 자체적으로 사용자의 입력 값을 제어하는 방식을 의미한다. 반면 비제어 컴포넌트는 useRef 훅을 사용하여 직접 DOM 요소를 참조함으로써 사용자가 입력하는 값을 획득하는 방식이다. 후자의 경우 당연히 React 자체적으로 입력 값 제어가 불가능하기 때문에 사용자가 입력 값을 변경한다고 해서 리렌더링이 발생하지 않는다. 비제어 컴포넌트 관련 내용은 하단 링크에서 더 자세히 살펴볼 수 있다.

📝 React 공식 문서의 비제어 컴포넌트

: https://ko.legacy.reactjs.org/docs/uncontrolled-components.html


😤 Form, 불편해도 너무 불편해!

 현대 웹 사이트의 Form을 한 번 생각해보자. 사용자가 값을 입력할 때마다 유효성 검사가 수행되어 Form 제출 전에도 잘못된 부분을 인식할 수 있도록 하는 방식은 굉장히 흔하게 찾아볼 수 있고, 특히 복잡한 Form이라면 UX 관점에서도 사용자에게 도움을 줄 수 있다. 그런데 앞서 살펴본 비제어 컴포넌트 방식으로는 사용자가 값을 입력할 때마다 그 값을 상태로 관리하지 않기 때문에 유효성 검사와 같은 동작을 수행할 수 없다. 즉 사용자가 입력한 값의 유효성을 즉각적으로 검사해야 하는 상황에서는 제어 컴포넌트 방식으로 Form을 구현하는 것이 필연적이다. 그러나 이 경우에는 또 다른 불편함이 있다. 하단의 코드를 살펴보자.

// ComplexForm.tsx

export default function ComplexForm() {
 const [value1, setValue1] = useState('');
 const [value2, setValue2] = useState('');
 const [value3, setValue3] = useState('');
 const [value4, setValue4] = useState('');
 const [value5, setValue5] = useState('');

 const handleChange1 = (event: React.ChangeEvent<HTMLInputElement>) =>
   setValue1(event.target.value);
 const handleChange2 = (event: React.ChangeEvent<HTMLInputElement>) =>
   setValue2(event.target.value);
 const handleChange3 = (event: React.ChangeEvent<HTMLInputElement>) =>
   setValue3(event.target.value);
 const handleChange4 = (event: React.ChangeEvent<HTMLInputElement>) =>
   setValue4(event.target.value);
 const handleChange5 = (event: React.ChangeEvent<HTMLInputElement>) =>
   setValue5(event.target.value);

 return (
   <form>
     <label htmlFor="input1">input 1</label>
     <input id="input1" type="text" onChange={handleChange1} />
     <label htmlFor="input2">input 2</label>
     <input id="input2" type="text" onChange={handleChange2} />
     <label htmlFor="input3">input 3</label>
     <input id="input3" type="text" onChange={handleChange3} />
     <label htmlFor="input4">input 4</label>
     <input id="input4" type="text" onChange={handleChange4} />
     <label htmlFor="input4">input 5</label>
     <input id="input5" type="text" onChange={handleChange5} />
   </form>
 );
}

 상단 예제에서 볼 수 있듯이 하나의 <input>에 입력 값을 저장할 useState 훅, change 이벤트 핸들러 등 따라오는 코드의 양이 상당하다. 심지어 유효성 검사 로직은 아직 포함되어 있지도 않다. 만약 Form이 관리해야 하는 <input>이 5개보다 더 많아지면 어떻게 될까? 당장 <input>의 개수가 5개인 예제만 봐도 복잡한데 그 복잡도는 상상을 초월할 것이다. 물론 Form 하위의 <input>을 컴포넌트로, 관련 비즈니스 로직을 커스텀 훅으로 분리하면 위와 같은 불편한 상황을 어느 정도는 개선할 수 있겠지만 <input> 요소의 개수가 늘어남에 따라 관리해야 할 상태와 비즈니스 로직의 복잡도가 증가한다는 사실에는 변함이 없다. 최근 진행한 사내 프로젝트에서도 비슷한 상황을 마주하게 되었다. 하나의 Form이 10개 이상의 <input>을 동적으로 관리하는 페이지를 구현해야 했다.


🤔 React-Hook-Form vs Formik

 그렇다면 React에서 Form 관리를 좀 더 쉽게 할 수 있는 방법이 없을까? 이러한 고민을 해결하기 위해 등장한 것이 바로 Form 관련 라이브러리다. 대표적으로 React-Hook-Form과 이번 글에서 소개할 Formik이 그것이다.

 상단 이미지는 npm trend 사이트에 Form 관련 라이브러리 다운로드 빈도를 그래프로 나타낸 결과다. React-Hook-Form이 1위, Formik이 2위를 차지하고 있는 것을 볼 수 있다. 글의 제목에서도 알 수 있듯이 사내 프로젝트에는 Formik을 도입했다. 그렇다면 왜 1위인 React-Hook-Form 대신 Formik을 도입하게 되었을까? 그 이유는 간단하다. 필자가 느끼기에 Formik이 React-Hook-Form보다 좀 더 선언적으로 Form 관리를 할 수 있다고 느꼈기 때문이다. 사실 필자는 이전에 개인 프로젝트에서 React-Hook-Form을 도입해본 경험이 있다. React-Hook-Form으로 Form을 관리할 때는 useForm 훅이 반환하는 register<input>의 props로 전달하는 방식으로 구현했었다. 다음과 같은 방식으로 말이다.

// React-Hook-Form

interface IFormInput {
  firstName: string;
  lastName: string;
}

export default function App() {
  const { register, handleSubmit } = useForm<IFormInput>();
  const onSubmit: SubmitHandler<IFormInput> = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName")} />
      <input {...register("lastName")} />
      <input type="submit" />
    </form>
  )
}

 상단 예제 코드는 React-Hook-Form 공식 문서에서 발췌했다. React-Hook-Form 라이브러리의 사용 방법을 요약해놓은 아주 좋은 예제인 것 같다. 아무튼 이전 프로젝트에서는 이러한 방식으로 Form을 구현했었는데, <input>props로 register를 전달하다 보니 컴포넌트 트리의 가독성도 저하되고 관심사의 분리 차원에서도 불편하다는 생각이 들었다. 그래서 다른 라이브러리가 없을까 리서치를 하던 중에 Formik을 발견하게 되어 이번 사내 프로젝트에 도입을 결정했다. Formik을 도입해야겠다고 결심한 결정적인 이유는 바로 하단 코드에 있다.

// Formik

interface InitialValues {
  firstName: string;
  lastName: string;
}

export default function App() {
  const initialValues = { firstName: '', lastName: '' };
  const handleSubmit = (data: InitialValues) => console.log(data);

  return (
    <Formik initialValues={initialValues} onSubmit={handleSubmit}>
      <Form>
        <Field type="text" name="firstName" />
        <Field type="text" name="lastName" />
        <input type="submit" />
      </Form>
    </Formik>
}

 앞서 살펴본 React-Hook-Form 버전의 코드를 Formik 버전으로 바꿔보았다. 큰 차이가 없다고 느껴질 수도 있지만, 필자는 후자인 Formik 버전이 훨씬 선언적이라고 판단했다. 물론 개인의 관점과 취향에 따라 다를 수 있다. 하지만 필자 기준으로는 컴포넌트 트리 부분에서도 가독성이 더 좋고, register 메서드가 수행하는 일이나 동작 방식을 굳이 알지 않아도 되기 때문에 사용자 입장에서 편리하다고 생각하여 사내 프로젝트 도입을 결정했다.


👊 Formik 뽀개기

 그렇다면 Formik, 어떻게 사용할까? 지금부터는 본격적으로 몇 가지 핵심 컴포넌트를 토대로 기본적인 Formik 사용법을 살펴보고자 한다.

📌 <Formik>

 먼저 Formik의 핵심 컴포넌트 <Formik>이다. <Formik>은 Form의 최상단 레이어에 위치해야 하며 Form 관련 상태 관리, 에러 처리, 제출 등의 동작을 쉽게 도와주는 역할을 한다. DOM 트리에 포함되는 실재적인 HTML 요소는 아니며, 하위에 있는 Form 관련 컴포넌트에 context를 제공하는 역할을 한다.

// Formik.tsx

export function Formik<
 Values extends FormikValues = FormikValues,
 ExtraProps = {}
>(props: FormikConfig<Values> & ExtraProps) {
 const formikbag = useFormik<Values>(props);
 const { component, children, render, innerRef } = props;
 
 // ... 중략 ...
 
 return (
   <FormikProvider value={formikbag}>
     {component
       ? React.createElement(component as any, formikbag)
       : render
       ? render(formikbag)
       : children // children come last, always called
       ? isFunction(children)
         ? (children as (bag: FormikProps<Values>) => React.ReactNode)(
             formikbag as FormikProps<Values>
           )
         : !isEmptyChildren(children)
         ? React.Children.only(children)
         : null
       : null}
   </FormikProvider>
 );
}

 상단의 코드는 실제 Formik 라이브러리 코드 중 <Formik> 컴포넌트 구현부를 발췌해온 것이다. 이를 살펴보면 <Formik> 컴포넌트는 useFormik 훅이 반환한 formikbag이라는 객체를 <FomikProvider> 컴포넌트로 하위 컴포넌트에 전달하는 역할을 하고 있다.

// FormikContext.tsx

export const FormikContext = React.createContext<FormikContextType<any>>(
  undefined as any
);
FormikContext.displayName = 'FormikContext';

export const FormikProvider = FormikContext.Provider;
export const FormikConsumer = FormikContext.Consumer;

export function useFormikContext<Values>() {
  const formik = React.useContext<FormikContextType<Values>>(FormikContext);

  invariant(
    !!formik,
    `Formik context is undefined, please verify you are calling useFormikContext() as child of a <Formik> component.`
  );

  return formik;
}

 그리고 위와 같이 <FormikProvider>는 React의 Context API를 이용하여 구현되었음을 확인할 수 있다. 구현된 라이브러리 코드를 뜯어보느라 이야기가 좀 길어졌지만, <Formik> 컴포넌트의 핵심은 Form 관리에 유용한 객체를 context로써 하위 컴포넌트에 전달한다는 것이다. 이 객체가 포함하는 다양한 프로퍼티와 메서드는 하단의 공식 문서 링크에서 자세히 살펴볼 수 있다.

📝 <Formik> 컴포넌트가 전달하는 context

: https://formik.org/docs/api/formik#formik-render-methods-and-props

 <Formik> 컴포넌트에는 필수적으로 전달해야 하는 두 가지 prop이 있다. 바로 initialValuesonSubmit이 그것이다. 이름 그대로 initialValues하위 <input>의 초기 값을 프로퍼티로 가지는 객체, onSubmit은 Form에 submit 이벤트가 발생하면 호출될 이벤트 핸들러다. 실제 사용 예제는 하단 코드를 토대로 살펴보자.

// <Formik> 사용 예제

export default function App() {
  return (
    <Formik
      initialValues={initialValues}
      validationSchema={validationSchema}
      onSubmit={handleSubmit}>
	  // ... 중략 ...
    </Formik>
  );
}

 상단 코드를 살펴보면 앞서 언급했던 initialValues, onSubmit 외에 validationSchema라는 prop이 <Formik> 컴포넌트에 추가로 전달된 것을 볼 수 있다. 이는 Yup 라이브러리를 활용하여 유효성 검사를 하기 위함인데, 자세한 내용은 추후 다른 게시글에서 다뤄볼 예정이다. 지금은 유효성 검사를 위한 스키마 객체를 <Formik> 컴포넌트에 전달한다는 정도로만 살펴보고 넘어가자.


📌 <Form>

 <Formik> 컴포넌트로 context를 전달했다면, 이를 전달 받을 <form> 요소가 있어야 할 것이다. 이는 Formik 라이브러리에서 <Form> 컴포넌트에 해당한다. 어차피 submit, reset 이벤트 처리는 다 앞서 살펴본 <Formik> 컴포넌트가 주관하기 때문에 <Form> 컴포넌트에 실질적으로 전달할 props는 없다. 즉 단순히 HTML 기본 <form> 요소를 대체하는 역할을 한다. 하단 코드로 간단하게 사용 예제를 확인해보자.

// <Form> 사용 예제

export default function App() {
  return (
    <Formik
      initialValues={initialValues}
      validationSchema={validationSchema}
      onSubmit={handleSubmit}>
	  <Form>
      // ... 중략 ...
      </Form>
    </Formik>
  );
}

 Formik의 <Form> 컴포넌트를 사용하지 않고 HTML 기본 <form> 요소를 사용하는 것도 불가능하지는 않다. 다만 아래와 같이 props를 수동으로 전달해줘야 한다는 점에서 다소 불편한 감이 있다.

// Formik 라이브러리의 <Form>
<Form />
 
// HTML 기본 <form> 요소
<form onReset={formikProps.handleReset} onSubmit={formikProps.handleSubmit} {...props} />

📌 <Field>

 다음으로 살펴볼 컴포넌트는 <Field>이다. <Field> 컴포넌트는 HTML 기본 <input> 요소를 대체하는 역할을 한다. <Field> 컴포넌트에 반드시 전달해야 하는 props는 없지만 중요한 props가 몇 개 있다.

🔸 name prop

 먼저 필자가 생각하기에 가장 중요한 prop, name이다. name은 말 그대로 <Field>의 이름인데, 이 name을 통해 어떤 <Field>의 입력 값이 변경되었는지 식별하여 값을 저장할 수 있다. 앞서 살펴봤던 <Formik> 컴포넌트에 필수적으로 전달해야 했던 initialValues 객체가 기억나는가? initialValues 객체 안에 있는 각 프로퍼티의 키 이름을 <Field> 컴포넌트의 name과 일치시키면 해당 프로퍼티의 값이 <Field> 컴포넌트의 초기 값이 된다. 글로는 와닿지 않을 수 있으니 다음의 예제를 통해 살펴보자.

// <Field> 사용 예제

export default function App() {
 // Form의 초기 값을 담고 있는 initialValues 객체
 const initialValues = { firstName: '' };
 
 return (
   <Formik
     initialValues={initialValues}
     validationSchema={validationSchema}
     onSubmit={handleSubmit}>
	  <Form>
       // name의 값을 firstName으로 할당하여 initialValues 객체의 firstName과 연동
       <Field name="firstName" />
     </Form>
   </Formik>
 );
}

 name prop으로 초기 값을 연동하는 것까지는 알겠다. 그런데 초기 값은 초기 값일 뿐, 사용자가 입력할 때마다 그 값이 initialValues 객체에 저장되지는 않을 것이다. 그렇다면 사용자가 입력할 때마다 <Field>는 그 입력 값을 어디에 갱신하여 저장하는 것일까? 그 해답은 바로 <Formik> 컴포넌트가 전달하는 context 객체 안의 values에 있다. 그리고 이 values는 다음과 같은 방식으로 하위 컴포넌트에서 참조가 가능하다.

// <Formik> 컴포넌트 하위에서 values 참조하기 

export default function App() {
 const initialValues = { firstName: '' };

 return (
   <Formik
     initialValues={initialValues}
     validationSchema={validationSchema}
     onSubmit={handleSubmit}>
     {({ values }) => (
       <Form>
         <Field name="firstName" />
       </Form>
     )}
   </Formik>
 );
}

💡 여기서 잠깐, useFormikContext

 그런데 이와 같은 참조 방식은 하나의 컴포넌트 안에 <Formik><Field>가 함께 있는 경우에만 가능하다. 만약 컴포넌트가 분리된 경우라면 props로 values를 넘겨 받아야 할 것이다. 하지만 props로 values와 같이 크기가 큰 객체를 전달하는 것은 컴포넌트 리렌더링 관점에서 결코 좋은 방식이라고 할 수 없을 것이다. 이럴 때 사용하기 좋은 훅이 있는데, 바로 useFormikContext이다. useFormikContext는 이름에서도 알 수 있듯이 앞서 살펴봤던 <Formik> 컴포넌트가 하위 컴포넌트에 context로써 전달하는 객체를 반환한다. 다만 사용할 때 주의할 점이 있다. <Formik> 컴포넌트 하위에서는 호출이 가능하지만, <Formik> 컴포넌트보다 상위에서 useFormikContext를 호출하려고 하면 당연히 참조가 불가능하기 때문에 에러가 발생한다. 하단의 코드로 실제 사용 방법을 살펴보자.

// useFormikContext 사용 예제

export default function SomethingField() {
  const { values } = useFormikContext();

  return <>{values.isSomething && <Field name="something" />}</>;
}

 위와 같이 useFormikContext 훅으로 values<Formik> 컴포넌트가 하위 컴포넌트에 전달하는 context의 다양한 프로퍼티, 메서드를 쉽게 활용할 수 있을 것이다. 단, useFormikContext를 호출하는 위치가 <Formik> 컴포넌트 하위라면 말이다.

🔸 type attribute

 다시 <Field> 컴포넌트 이야기로 돌아와서, name외에 <Field> 컴포넌트에 type prop을 전달할 수 있다. 사실 엄밀히 말하면 type은 prop이라기 보다 HTML 기본 <input> 요소의 type 어트리뷰트라고 보는 편이 맞다. Formik 라이브러리 공식 문서에서도 <Field> 컴포넌트 명세에 type을 prop으로 정의하고 있지는 않다. 아무튼 <Field>에는 type을 지정할 수 있는데, HTML 기본 <input> 요소의 type 어트리뷰트와 동일하게 text, checkbox, number, email, radio, date 등의 값을 할당하면 된다. 다만 주의할 점이 있다면 <input> 요소의 모든 type 값을 지원하는 것은 아니다. 기본적으로 <Field> 컴포넌트는 file, image 등 지원하지 않는 type이 있다. 그러나 커스터마이징을 통해 지원하는 것처럼 바꿀 수는 있다. 이는 기회가 된다면 다른 게시글에서 작성해보고자 한다. <Field> 컴포넌트에 type을 지정한 예제는 하단 코드에서 확인 가능하다.

// <Field> type 사용 예제

export default function App() {
 const initialValues = {
   name: '',
   checkbox: true,
   date: '2024-03-01',
   email: 'abcd1234@gmail.com',
   number: 20,
   password: 'password',
   radio: false,
   range: 30,
   search: '',
   tel: '',
 };

 return (
   <Formik
     initialValues={initialValues}
     validationSchema={validationSchema}
     onSubmit={handleSubmit}>
     <Form>
       <Field type="text" name="name" placeholder="이름을 입력하세요." />
       <Field type="checkbox" name="checkbox" />
       <Field type="date" name="date" />
       <Field type="email" name="email" placeholder="이메일을 입력하세요." />
       <Field type="number" name="number" min="20" max="100" />
       <Field type="password" name="password" placeholder="비밀번호를 입력하세요." />
       <Field type="radio" name="radio" />
       <Field type="range" name="range" min="0" max="40" />
       <Field type="search" name="search" placeholder="검색어를 입력하세요." />
       <Field type="tel" name="tel" placeholder="전화번호를 입력하세요." />
     </Form>
   </Formik>
 );
}

 상단 예제를 살펴보면 <Field>는 기본적으로 HTML 기본 <input> 요소를 대체할 수 있기 때문에 type에 해당하는 추가적인 어트리뷰트 역시 지정이 가능하다. 이를 테면 typetext<Field>placeholder를 추가로 지정한다든가, typerange<Field>minmax를 추가로 지정하는 것처럼 말이다. 비슷한 맥락으로 <label> 역시 <Field> 컴포넌트와 연동이 가능하다. 하단 예제 코드를 확인해보자.

export default function App() {
  const initialValues = {
    name: '',
  };

  return (
    <Formik
      initialValues={initialValues}
      validationSchema={validationSchema}
      onSubmit={handleSubmit}>
      <Form>
        <label htmlFor="name">이름</label>
        <Field
          id="name"
          type="text"
          name="name"
          placeholder="이름을 입력하세요."
        />
      </Form>
    </Formik>
  );
}

 위와 같이 Formik 라이브러리를 사용하기 전과 동일하게 <label>for 어트리뷰트 값과 <Field>id 어트리뷰트 값을 일치시켜주면 된다. 다만 React 환경에서는 for가 아니라 htmlFor 어트리뷰트를 지정해야 함에 유의하자.

🔸 as prop

 여기서 한 가지 의문점이 생긴다. imagefile 타입을 제외하고 웬만하면 HTML 기본 <input> 요소는 <Field> 컴포넌트로 대체가 가능하다는 것은 알겠다. 그런데 <textarea><select>처럼 요소 이름 자체가 <input>이 아닌 경우는 Formik 라이브러리의 지원 범위 밖에 있는 것일까? 물론 그렇지 않다. 이런 경우에는 <Field> 컴포넌트의 as prop을 사용하면 된다. as prop은 말 그대로 <Field>를 ~로 사용하겠다는 의미로 이해하면 된다. 공식 문서에 따르면 as에 지정할 수 있는 값은 다음과 같다.

<Field>as prop에 할당 가능한 값

Either a React component or the name of an HTML element to render. That is, one of the following:

  • input
  • select
  • textarea
  • A valid HTML element name
  • A custom React component

 즉 as prop의 값으로는 input, select, textarea, 유효한 HTML 요소 이름(div, p 등), React 커스텀 컴포넌트를 할당할 수 있다. 이 중에서 사용할 일이 있다면 아마 selecttextarea 정도를 주로 사용하게 될 테니, 관련 예제를 하단 코드로 작성해보았다.

export default function App() {
 const initialValues = {
   food: 'hamburger',
   contents: '',
 };

 return (
   <Formik
     initialValues={initialValues}
     validationSchema={validationSchema}
     onSubmit={handleSubmit}>
     <Form>
       <Field as="select" name="food">
         <option value="hamburger">Hamburger</option>
         <option value="pizza">Pizza</option>
         <option value="chicken">Chicken</option>
       </Field>
       <Field as="textarea" name="contents" placeholder="긴 글을 입력하세요."/>
     </Form>
   </Formik>
 );
}

 as prop은 위와 같이 사용하면 된다. 다만 <select> 요소처럼 사용하려면 동일하게 <Field> 컴포넌트의 자식 요소로 <option> 요소를 삽입해야 원하는 방식대로 사용할 수 있음에 유의하면 좋을 듯하다. 이외에 <Field> 컴포넌트와 관련된 보다 자세한 내용은 하단 공식 문서 링크에서 확인할 수 있다.

📝 Formik 라이브러리의 <Field> 컴포넌트

: https://formik.org/docs/api/field


📌 <ErrorMessage>

 마지막으로 살펴볼 컴포넌트는 바로 <ErrorMessage>이다. <ErrorMessage> 컴포넌트는 이름에서도 유추 가능하듯 유효성 검사를 통과하지 못한 경우 에러 메시지를 출력해주는 컴포넌트다. <ErrorMessage> 컴포넌트에도 필수적으로 전달해야 하는 prop이 있는데 바로 name이다. 예상이 가능하겠지만 <Field> 컴포넌트의 name과 값을 일치시키면 해당 <Field> 컴포넌트의 유효성 검사를 수행하여 통과하지 못한 경우 에러 메시지를 렌더링한다. 그렇다면 유효성 검사 로직은 어떻게 작성할 수 있을까? 앞서 <Field> 컴포넌트에 전달 가능한 props를 소개할 때 일부러 소개하지 않은 prop이 하나 있는데, 바로 validate다. validate의 값에는 <Field> 컴포넌트의 유효성 검사를 수행하는 함수를 전달할 수 있다. 하단 예제처럼 말이다.

// <ErrorMessage>와 validate 사용 예제

export default function App() {
  const initialValues = {
    name: '',
  };

  const validateName = (name: string) => !name && '이름은 필수입니다.';

  return (
    <Formik
      initialValues={initialValues}
      onSubmit={handleSubmit}>
      <Form className="flex flex-col items-start max-w-[200px] m-8">
        <label htmlFor="text">이름</label>
        <Field
          id="text"
          type="text"
          name="name"
          placeholder="이름을 입력하세요."
          validate={validateName}
        />
        <ErrorMessage name="name" />
      </Form>
    </Formik>
  );
}

 아주 단순하게 <Field>의 값을 필수적으로 입력해야 한다는 조건의 유효성 검사 함수를 정의하여 validate prop의 값으로 할당해주었다. 그 결과 <Field>의 값을 입력하지 않으면 상단 이미지처럼 "이름은 필수입니다."라는 에러 메시지가 렌더링되는 모습을 볼 수 있다. 물론 복잡한 유효성 검사 함수를 커스터마이징해야 하는 상황이라면 위와 같이 validate prop을 유용하게 활용할 수 있다. 하지만 대부분의 경우 앞서 잠깐 언급했던 Yup 라이브러리와 validationSchema 객체를 활용하면 훨씬 간편하게 유효성 검사가 가능하다. 바로 아래처럼 말이다.

// <ErrorMessage>와 Yup 라이브러리 사용 예제

export default function App() {
  const initialValues = {
    name: '',
  };

  const validationSchema = object({
    name: string().required('이름은 필수적인 값입니다.'),
  });

  return (
    <Formik
      initialValues={initialValues}
      validationSchema={validationSchema}
      onSubmit={handleSubmit}>
      <Form className="flex flex-col items-start max-w-[200px] m-8">
        <label htmlFor="text">이름</label>
        <Field
          id="text"
          type="text"
          name="name"
          placeholder="이름을 입력하세요."
        />
        <ErrorMessage name="name" />
      </Form>
    </Formik>
  );
}

 간단하게만 살펴보면 상단 예제의 validationSchema 객체는 initialValues와 동일한 구조로 구성되어 있다. 다만 프로퍼티의 값이 Yup 라이브러리에서 import 해온 string 메서드라는 점이 특징적이다. 앞서 살펴본 예제와 비교해보면 validate prop에 전달할 함수를 직접 정의했던 것보다 훨씬 간편하게 유효성 검사를 수행할 수 있고, 객체 구조로 되어 있어 각 <Field>의 유효성 검사 내용을 한 눈에 파악하기도 용이하다.

 유효성 검사와 관련하여 한 가지만 더 자세하게 살펴보자. Formik 라이브러리는 수행한 유효성 검사의 결과를 어디에 저장하길래 별다른 설정 없이 <ErrorMessage> 컴포넌트에서 에러 메시지를 렌더링하는지 궁금하지 않는가? 앞서 살펴봤던 <Formik> 컴포넌트가 하위 컴포넌트에 전달하는 context 객체에 그 해답이 있다. context 객체에는 각 <Field>의 입력 값을 저장하고 있는 values 객체가 프로퍼티로 존재했다. values 외에도 다양한 프로퍼티와 메서드가 있다고 공식 문서를 링크하며 넘어갔었는데, errors라는 객체 역시 context 객체의 프로퍼티로 존재한다. 만약 유효성 검사를 통과하지 못하는 경우 errors 객체는 <Field> 컴포넌트에 prop으로 전달했던 name 키의 값으로 에러 메시지를 저장한다. 다음과 같은 방식으로 말이다.

// errors 객체 참조 예시

export default function App() {
  const initialValues = {
    name: '',
  };

  const validationSchema = object({
    name: string().required('이름은 필수적인 값입니다.'),
  });

  return (
    <Formik
      initialValues={initialValues}
      validationSchema={validationSchema}
      onSubmit={handleSubmit}>
      {({ errors }) => {
        console.log(errors);

        return (
          <Form>
            <label htmlFor="text">이름</label>
            <Field
              id="text"
              type="text"
              name="name"
              placeholder="이름을 입력하세요."
            />
            <ErrorMessage name="name" />
          </Form>
        );
      }}
    </Formik>
  );
}

 name의 값이 name<Field> 컴포넌트에 아무런 값도 입력하지 않을 경우 콘솔 창에 찍히는 에러 객체를 확인해보면 상단 이미지와 같다. 정리하면 유효성 검사를 통과하지 못한 경우 <Formik> 컴포넌트가 전달하는 context의 errors 객체에 그 정보를 저장한다. 이때 키 이름은 해당 <Field> 컴포넌트의 name prop에 할당된 값으로, 그 값은 validationSchema 객체의 값으로 할당한 문자열이 된다. 최종적으로 <ErrorMessage> 컴포넌트에 전달한 name prop의 값을 토대로 errors 객체를 탐색하여 찾아낸 값이 Text Node로 렌더링되는 것이다. 사실 실제로 사용하면서는 위와 같은 방식으로 errors 객체를 직접 참조하기 보다, 에러 처리를 커스터마이징할 때 유용하게 활용해볼 수 있을 것 같다.

 지금까지 살펴본 것처럼 Yup 라이브러리는 Formik 라이브러리 공식 문서에서도 추천하고 있을 정도로 호환성이 좋고 간편한 유효성 검사 도구다. 이번 글에서는 너무 길어질 것 같아서 Yup 라이브러리를 활용한 유효성 검사 방법에 대해서는 다른 게시글에서 자세하게 다뤄볼 예정이다.


🚨 한 가지만 더, <ErrorMessage> 스타일링 이슈

 글을 마무리하기 전에 <ErrorMessage> 컴포넌트와 관련하여 미세한 꿀팁을 하나 소개하고자 한다. 바로 <ErrorMessage> 컴포넌트를 스타일링하는 방법이다. <Formik> 컴포넌트를 제외하고 <Form>이나 <Field>처럼 실재적인 HTML 요소가 렌더링되는 컴포넌트들은 컴포넌트에 바로 CSS 스타일링 적용이 가능하다. 그런데 <ErrorMessage> 컴포넌트는 컴포넌트에 바로 CSS 스탸일링 적용이 되지 않는다. 하단 예제를 살펴보자.

// <ErrorMessage> 컴포넌트 스타일링 - ❌ 잘못된 버전 ❌

export default function App() {
  return (
    <Formik
      initialValues={initialValues}
      validationSchema={validationSchema}
      onSubmit={handleSubmit}>
      <Form className="flex flex-col items-start max-w-[200px] p-4 m-8 rounded-lg bg-violet-300">
        <label htmlFor="text">이름</label>
        <Field
          id="text"
          type="text"
          name="name"
          placeholder="이름을 입력하세요."
          className="pl-1 rounded-xl text-sm"
        />
        <ErrorMessage name="name" className="text-red-500 text-sm" />
      </Form>
    </Formik>
  );

 상단 예제를 살펴보면 <Formik><Form> 컴포넌트에는 스타일링이 정상적으로 적용되었지만, <ErrorMessage> 컴포넌트에는 스타일링이 적용되지 않은 것을 확인할 수 있다. 필자는 편의성을 위해 TailwindCSS 라이브러리를 사용하여 스타일링했지만, Module CSS나 Sass 등의 방법을 사용해도 아마 결과는 동일할 것이다. 왜 이런 문제가 발생하는 것일까? 바로 그 이유는 <ErrorMessage> 컴포넌트가 Element Node로 렌더링되는 것이 아니라 Text Node로 렌더링되기 때문이다. 개발자 도구의 요소 탭을 확인해보면 이를 쉽게 확인할 수 있다.

 <Form> 컴포넌트는 <form>으로, <Field> 컴포넌트는 <input>으로 렌더링되었지만 <ErrorMessage> 컴포넌트는 별다른 HTML 요소 없이 텍스트 자체만 렌더링되었다. 그래서 클래스 이름을 적용해도 아무런 효과가 없었던 것이다. 그렇다면 <ErrorMessage> 컴포넌트는 스타일링할 방법이 없는 걸까? 그렇지 않다. <ErrorMessage> 컴포넌트를 임의의 HTML 요소로 한꺼풀 감싸고, 감싸준 HTML 요소에 스타일링을 적용하면 된다. 하단 예제처럼 말이다.

// <ErrorMessage> 컴포넌트 스타일링 - ⭕️ 올바른 버전 ⭕️

export default function App() {
 return (
   <Formik
     initialValues={initialValues}
     validationSchema={validationSchema}
     onSubmit={handleSubmit}>
     <Form className="flex flex-col items-start max-w-[200px] p-4 m-8 rounded-lg bg-violet-300">
       <label htmlFor="text">이름</label>
       <Field
         id="text"
         type="text"
         name="name"
         placeholder="이름을 입력하세요."
         className="pl-1 rounded-xl text-sm"
       />
       <p className="text-red-500 text-sm">
         <ErrorMessage name="name" />
       </p>
     </Form>
   </Formik>
 );

 짜잔! 위와 같이 정상적으로 스타일링이 적용된 모습을 볼 수 있다.


😊 편ㅡ안한 Formik!

 지금까지 React에서 Form을 유용하게 제어할 수 있는 라이브러리, Formik에 대해 살펴보았다. 확실히 프로젝트에 어떤 기술 스택을 도입하기 전에 유사한 기술 스택끼리 비교해보고, 도입했을 때의 이점에 대해 고찰하는 시간을 가지는 일은 필수적인 것 같다. 이번 글에서는 Formik 라이브러리의 기본 사용법에 초점을 맞춰 소개해보았는데, 최근 진행했던 프로젝트에서 동적인 Form을 제어하면서 가장 도움을 받았던 Formik의 컴포넌트는 바로 <FieldArray>였다. 이는 추후 Yup 라이브러리를 활용한 유효성 검사 방법을 자세히 살펴볼 다른 게시글에서 함께 소개해보고자 한다. 이 글을 보는 여러분이 Formik 라이브러리를 활용하여 쉽게 Form을 관리하는 데 도움이 되셨으면 하는 마음으로 글을 마무리하겠다.

🙏 출처

https://react-hook-form.com/
https://formik.org/
https://github.com/jaredpalmer/formik

profile
𝙸'𝚖 𝚊 𝚍𝚎𝚟𝚎𝚕𝚘𝚙𝚎𝚛 𝚝𝚛𝚢𝚒𝚗𝚐 𝚝𝚘 𝚜𝚝𝚞𝚍𝚢 𝚊𝚕𝚠𝚊𝚢𝚜. 🤔

0개의 댓글