[Revisit Project] React에서 form을 다루는 Best Case는 무엇일까?

2023. 9. 29. 00:02Project Tours/Tour on Plantopia

1. 고민의 시작

Plantopia라는 식물 관리 어플리케이션을 만들면서 식물 등록을 위한 form을 다루게 되었다. 처음 코드를 작성할 때는 여러 개 state를 두고 각각에 onChange 함수를 두는 방식으로 작성했다. 또한, 에러 역시 하나하나 조건을 걸며 작성해야하는 것 또한 반복되었다. 프로젝트 당시 작성을 하면서도 여러번 반복을 하는 코드를 적는 것에 심적으로 불편했다. 하지만, 프로젝트를 할 때는 3주 밖에 안되는 시간에 기획부터 시작하려니 이런 고민을 더 이어나가지 못했는데 프로젝트가 끝난 후 form을 다루는 좋은 방법을 찾아나서게 되었다. 

 

2. 고민 해결의 과정

처음 코드는 아래와 같았다.

고민 - 1 . 중복된 코드로 코드 길이가 길어지고 가독성이 떨어지는 문제 

const [searchInputValue, setSearchInputValue] = useState(name);
  const [plantName, setPlantName] = useState<string>('');
  const [purchasedDay, setPurchasedDay] = useState<string>('');
  const [wateredDays, setWateredDays] = useState<string>('');
  const [frequency, setFrequency] = useState(waterCodeToNumber(waterCode));
  const [imgUrl, setImgUrl] = useState<string | null>(null);
  const [previewImg, setPreviewImg] = useState<string>();
  const [saving, setSaving] = useState(false);
  const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchInputValue(e.target.value);
  };
  const navigateSearch = () => {
    navigate('/dict/search', {
      state: { inputValue: '' },
    });
  };
  const plantNameHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPlantName(e.target.value);
  };
  const handleFrequency = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFrequency(Number(e.target.value));
  };
  const purchasedDayHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPurchasedDay(e.target.value);
  };
  const wateredDaysHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    setWateredDays(e.target.value);
  };

 

> 때로는 중복이 나을 수 있다고 하지만 만약 input이 늘어난다면 찾기 힘들고 지금과 같이 변수명이 통일되지 않는 상태에서 변수명을 바꾼다면 혹은 input을 관리하는 함수에 추가 기능을 넣는다면 그 확장성에 문제가 생기지 않을까 생각이 들었다. 그리고 이런 부분에서 가장 먼저 떠오르는 해결책은 커스텀 훅이었다. 

 

import { useState } from 'react';

const useInput = input => {
  const [value, setValue] = useState(input);
  const handleValue = e => {
    setValue(e.target.value);
  };

  return { value, handleValue };
};

 

> 하지만, 여기서 이 전 배웠던 렌더링 최적화에 대한 이야기가 생각났다. 커스텀 훅은 코드의 수를 줄여줄 수 있지만 onChange 함수를 통해서 input값이 변할 때마다 state를 변하게 하고 state는 변화는 렌더링을 일으킨다. 사실 우리에게 필요한 것은 form이 완성되었을 때만 state 값을 변경하는 것이다. 

 

> 그러다가 인프런의 글을 보게되었다. 

https://tech.inflab.com/202207-rallit-form-refactoring/react-hook-form/

 

react-hook-form을 선택한 이유와 적용 과정

IT 채용 플랫폼 랠릿의 거대한 Form을 react-hook-form으로 마이그레이션한 이유와 과정을 공유합니다.

tech.inflab.com

 

> 해당 내용에서 눈여겨 볼 점은 react-hook-form이 비제어 컴포넌트 방식으로 구현되어 있어 state를 사용하지 않는다는 점이다. 그렇기 때문에 불필요한 리렌더링을 막을 수 있었다. 그래서 이와 더불어 많은 기업들이 이미 도입해 쓰고 있기에  써봐야겠다고 결정했고 이 결정과 별개로 라이브러리 도입에 대해서 배운 것이 있었다. 나는 혼자 프로젝트를 만들고 있어서 도입 결정에 어려움이 없지만 회사에서는 많은 사람과 일하다보니 새로운 라이브러리 도입 시 많은 사람들이 모두 그 라이브러리를 배워야하고 더 큰 규모에 프로젝트에서는 전반적인 코드 상의 안정성을 따져야한다는 것을 배웠다.

고민 - 2 . 에러 처리 및 추가에 대한 어려움

 const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setSaving(true);
    if (!searchInputValue) {
      errorNoti('식물을 지정해주세요.');
      setSaving(false);
      return;
    }
    if (!plantName) {
      errorNoti('식물 닉네임을 설정해주세요.');
      setSaving(false);
      return;
    }
    if (!frequency) {
      errorNoti('식물의 물 주기를 설정해주세요.');
      setSaving(false);
      return;
    }
    if (!purchasedDay) {
      errorNoti('식물과 함께한 날을 지정해주세요.');
      setSaving(false);
      return;
    }

    const q = query(
      collection(db, 'plant'),
      where('userEmail', '==', user?.email),
    );
    const querySnapshot = await getDocs(q);
    const isEmpty = querySnapshot.empty;
    const newPlantData = {
      frequency: waterCodeToNumber(waterCode),
      imgUrl: imgUrl || image,
      isMain: isEmpty ? true : false,
      nickname: plantName,
      plantName: searchInputValue,
      purchasedDay: dateToTimestamp(purchasedDay),
      userEmail: user?.email,
      wateredDays: wateredDays ? [dateToTimestamp(wateredDays)] : [],
    };
    await addDoc(collection(db, 'plant'), newPlantData);

    successNoti('새 식물 등록에 성공하였습니다');
    navigate('/myplant');
  };

 

> 코드 중복을 줄일 수 있고 불필요한 렌더링 문제를 해결했지만 react-hook-form 꼭 써보고 싶다는 생각을 한 것은 라이브러리에서 제공하는 다양한 메서드가 그 중 에러 처리를 아주 수월하게 해결할 수 있었다. onSubmit에 함수로 두 개의 인자를 받는데 vaild, invalid할 때를 함수로 받아 분리해서 처리할 수 있어 이해하기 쉽고, 위 코드를 보면 저장할 때 모든 에러를 하나하나 처리했어야하는데 제공해주는 메서드들이 훨씬 수월하게 처리하게 해준다. 

 

3. 적용

const onInvalid = (errors: FieldErrors) => {
    for (const fieldName in errors) {
      if (errors[fieldName]?.message) {
        const message = errors[fieldName]?.message as string;
        errorNoti(message);
        return;
      }
    }
  };

const onValid = async (data: UserPlantForm) => {
    setSaving(true);
    if (!user?.email) return;
    const isEmpty = await isUserPlantEmpty(user?.email);
    const newPlantData = {
      frequency: data.frequency,
      imgUrl: imgUrl || image,
      isMain: isEmpty ? true : false,
      nickname: data.nickname,
      plantName: data.plantName,
      purchasedDay: dateToTimestamp(data.purchasedDay),
      userEmail: user?.email,
      wateredDays: data.wateredDays ? [dateToTimestamp(data.wateredDays)] : [],
    };
    registerPlantData(newPlantData as UserPlant);
    successNoti('새 식물 등록에 성공하였습니다');
    navigate('/myplant');
  };

 

> 저 위의 코드가 아래와 같은 코드로 변모하였다. 무엇보다도 눈에 띄는 점은 코드 수를 획기적으로 줄인 점이다. 생각보다 적용이 어려웠던 점은 프로젝트에서 react-toasfify 라이브러리와 함께 적용하는 것에서 헤맸다. react-hook-form에서 error 메서드는 새로 html을 만들어서 에러 메시지를 띄우는 방식을 주로 알려주는데 방법을 찾다가 toastify를 invalid 함수로 빼주었다.

 

> 추가적으로 인프런의 글을 읽으면서 필자의 고민의 깊이를 느껴볼 수 있었다. 지금의 나는 미약하지만 좀 더 지식과 경험을 쌓아 저 깊이를 따라잡고 싶다.