백오피스 개발일지 [2] - Ant Design Form 활용

최원빈·2022년 11월 27일
2

React에서 Form을 다루다보면 생각보다 손이 많이 갈 수밖에 없다.
상태를 어떻게 관리할 지(controlled, uncontrolled)부터 시작해서.. 초기값 적용을 위한 transform 과정, 제출 직전 값을 body 구조에 맞게 변경하는 작업, 유효성 체크 등등등..

Form만 전문적으로 다루는 라이브러리들도 많이 나오고 있다.

이번에도 Form 상태를 관리하기 위해 무언가를 채택할까 했는데, Ant Design의 Form이 UI뿐만 아니라 꽤나 다양한 기능을 제공한다는 걸 듣고, 별도의 라이브러리를 설치하기보단 이를 적극 활용해보기로 했다.

유저 정보 수정 페이지를 개발하며 useForm, rule, Form.Item 등 내부 요소를 활용하고 커스텀 합성 컴포넌트로 래핑한 경험을 공유하려 한다.


Form 기본 활용

function DetailForm() {
  const { id } = useParams();
  const { data: userData } = useGetUserQuery(Number(id));
  /* 
  userData = {
    name: "최원빈",
    id: 43,
    student_number: 2019136133,
    ...
  */
  const [form] = Form.useForm();
  
  return (
    <div>
      {userData && (
        <Form form={form} initialValues={userData}>
          <Form.Item name="id" label="유저 ID">
            <Input />
          </Form.Item>
          <Form.Item name="name" label="유저명">
            <Input />
          </Form.Item>
        </Form>
      )}
    </div>
  )
}

데이터를 initialValues나, fields 라는 프로퍼티에 담아주면 자동으로 name에 해당하는 값에 매핑된다.

두 프로퍼티에 차이는, initialValuesuncontrolled에 가깝고(최초 마운트 이후 초기값으로만 의미를 가짐), fieldscontrolled에 가깝다.(fields의 변경을 감시한다)

이번 프로젝트의 경우, RTK-Query를 서버 상태 관리 라이브러리로 사용했는데, 다른 redux store가 갱신되면 userData로 만든 entries의 참조가 새로 만들어져 fields가 변경됐다고 판단하고, 수정해둔 필드들이 전부 초기화되는 문제가 발생했다.

이러한 초기화가 필요한 구조라면 fields를, 아니라면 initialValues를 목적에 맞게 채택하면 될 것 같다.

useForm이 반환하는 form을 <Form>에 연결하면, FormInstance가 제공하는 다양한 메소드를 사용할 수 있다.
주로 Get, Set과 연관이 있고, 이후에 form.getFieldValue(name)메소드를 활용할 예정이다.

CustomForm으로 래핑

그런데 작성하다보면 Form.Item을 활용하는 부분이 중복으로 작성되고, 공통으로 사용할 스타일링도 적용시킬 필요가 있어 이를 모아둔 래핑 컴포넌트를 만들어 쓰기로 했다.

import { Form, Input, InputProps } from 'antd';
import * as S from './CustomForm.style';

interface FormItemProps {
  label: string;
  name: string;
  disabled?: boolean;
  rules?: Rule[];
}

function CustomInput({
  label, name, rules, disabled, ...args
}: FormItemProps & InputProps) {
  return (
    <S.FormItem label={label} name={name} rules={rules}>
      <S.StyledInput disabled={disabled} {...args} />
    </S.FormItem>
  );
}

const CustomForm = Object.assign(Form, {
  Button: CustomButton,
  Input: CustomInput,
  TextArea: CusctomTextArea,
  Upload: CustomUpload,
  Checkbox: CustomCheckbox,
  Select: CustomSelect,
});

export default CustomForm;

이렇게 합성 컴포넌트로 모아두면, 사용하는 쪽에서 Import할 파일의 수가 줄고, Form.Item로 매번 감쌀 필요가 사라져 코드를 간소화할 수 있다.
거기에 통일된 스타일링을 유지할 수 있는 것은 덤.

function DetailForm() {
  const { id } = useParams();
  const { data: userData } = useGetUserQuery(Number(id));
  const [form] = CustomForm.useForm();
  
  return (
    <div>
      {userData && (
        <CustomForm form={form} initialValues={userData}>
          <CustomForm.Input name="id" label="유저 ID"/>
          <CustomForm.Input name="name" label="유저명"/>
        </CustomForm>
      )}
    </div>
  )
}

벌써부터 코드 구조가 상당히 깔끔해진 것이 마음에 든다.

Form 제출

<Form />onFinish props에 제출 함수를 담아주면 submit을 갖는 버튼과 연결된다.

컴포넌트 내부에서의 선언적인 코드를 유지하기 위해 mutation에 해당하는 로직을 분리한 useUserMutation 훅을 만들어 분리했다.

export default function useUserMutation() {
  const [updateUserRequest] = useUpdateUserMutation();
  const navigate = useNavigate();

  const updateUser = (formData: UserDetail) => {
    if (formData) {
      updateUserRequest(formData)
        .unwrap()
        .then(() => {
          makeToast('success', '정보 수정이 완료되었습니다.');
          navigate(-1);
        })
        .catch(({ data }) => {
          makeToast('error', data.error.message);
        });
    }
  };

  return { updateUser };
}

추후에 추가할 삭제, 추가도 해당 훅에서 제공함으로써, 관심사의 분리를 명확히 할 수 있을 것이다.

function DetailForm() {
  const { id } = useParams();
  const { data: userData } = useGetUserQuery(Number(id));
  const [form] = CustomForm.useForm();
  const { updateUser } = useUserMutation();
  
  return (
    <div>
      {userData && (
        <CustomForm 
          form={form} 
          initialValues={userData}
          onFinish={updateUser}>

rules 활용

Form.Itemrules props를 통해 validation을 제공한다.
정규표현식 pattern을 적거나, 빈 값이 들어올 수 없는 필드에는 require를 추가하는 것만으로 간단한 유효성 검사가 가능하다.
또한 type: "email" 을 추가하기만 해도 이메일 정규표현식을 검사해준다.

지금 만드는 유저 상세 정보 수정 페이지는 닉네임 필드를 변경해서 PUT요청을 보낼 수 있지만, 먼저 중복 확인 과정이 필요했다.

  • 닉네임 값을 바꾼 상태라면, 먼저 중복 검증이 필요하다.
  • 닉네임을 바꾼 상태일 땐, 정보 수정 요청이 불가능해야한다.
  • 닉네임을 바꾸고 중복 검증이 성공적으로 완료되었다면, 수정 요청이 가능해야한다.

rules에 validator를 추가함으로써 이 문제를 해결할 수 있을 것이라 생각했다.
validatorPromise.resolve() / reject()를 반환하는 함수를 넣어줘야 한다.
지금 내 경우는 닉네임이라는 필드에 대한 관리가 필요하기 때문에, 이번에도 선언적인 문맥 유지를 위해 hook으로 분리했다.

export default function useNicknameCheck(form: FormInstance) {
  // 유효 상태를 관리할 변수
  const [nicknameChecked, setNicknameChecked] = useState(true);
  const [checkNickname] = useGetNicknameCheckMutation();

  // nicknameChecked가 수정될 때마다 유효성 검증을 강요
  useEffect(() => {
    form.validateFields(['nickname']);
  }, [nicknameChecked, form]);

  // 닉네임이 변경되면, 유효성 검증이 실패하도록 값을 수정
  const handleNicknameChange = () => {
    setNicknameChecked(false);
  };

  const checkDuplicateNickname = () => {
    checkNickname(form.getFieldValue('nickname'))
      .unwrap()
      .then(() => {
        makeToast('success', '사용 가능한 닉네임입니다.');
        // 닉네임 중복검사 요청이 성공했다면, 유효 상태를 true로 변경
        setNicknameChecked(true);
      })
      .catch(({ data }) => {
        makeToast('error', data.error.message);
        setNicknameChecked(false);
      });
  };
  
  // validator는 유효 상태에 따라 resolve를 반환
  const validator = () => (nicknameChecked ? Promise.resolve() : Promise.reject(new Error('닉네임 중복을 확인해주세요')));

  return { handleNicknameChange, checkDuplicateNickname, validator };
}

핵심인 validator와 닉네임이 변경될 때에 적용될 handleNicknameChange, 중복검증을 일으킬 버튼에 담아줄 checkDuplicateNickname까지 반환했으니 알맞게 넣기만 하면 된다.

function DetailForm() {
  const { updateUser } = useUserMutation();
  const { handleNicknameChange, checkDuplicateNickname, validator } = useNicknameCheck(form);
  
  return (
    <div>
      {userData && (
        <CustomForm 
          form={form} 
          initialValues={userData}
          onFinish={updateUser}>
          //(...)
          <CustomForm.Input label="닉네임" name="nickname" onChange={handleNicknameChange} rules={[{ validator }]} />
          <CustomForm.Button onClick={checkDuplicateNickname}>중복확인</CustomForm.Button>

추후에는 해당 기능이 ID중복검사 등 다른 곳에서도 사용할 일이 생긴다면 nickname뿐 아니라 다른 필드에 대해서도 사용할 수 있게끔 추상화할 수 있겠지만, 당장 어드민에서 생각난 기능에는 없다고 판단해 일단은 넘어갔다.


후기

Ant Design의 Form이 생각보다 매우 편리했다.
Form관리에 있어서 신경쓸 많은 부분들을 별도의 라이브러리 설치 없이 관리할 수 있다는 점이 너무 좋았다.(Antd 자체는 이미 UI라이브러리로 선정해서 설치했으니..)

직접 관리하려면 복잡했을 UI 상태지만, Form을 래핑하고 이에 의존하여 작성하는 부분에 있어선 정말 간단하게 짤 수 있었다.

UI를 제외한 다른 로직을 무려 5줄만으로 분리해 작성할 수 있었다.

기존의 폼 상태를 다루던 방식인..

setState({
  ...state
  [e.target.name]: e.target.value,
})

뭐... 이런 코드나.. useRef를 덕지덕지 사용하던 코드를 벗어나 깔끔하게 작성할 수 있었다는 점이 만족스러웠다.

profile
FrontEnd Developer

0개의 댓글