useReducer로 useState 대신 복잡한 input들을 효과적으로 관리하기

React에서 class형 컴포넌트를 주로 사용할 때는, setState로 객체를 저장했기 때문에 여러 인풋을 다루기 위해 매번 반복적인 코드를 추가할 필요가 적었다. 한편, functional 컴포넌트를 주로 사용하는 현재, 단순히 input 하나 당 useState 하나를 사용할 경우, 코드는 금세 스파게티가 되고, 가독성이 심각하게 떨어진다.

물론, 하나의 컴포넌트에서 그렇게 많은 state를 다루기 보다는 각 하위 컴포넌트로 나누는게 방법일 수도 있다. 하지만 이렇게 될 경우 각 컴포넌트가 독자적으로 데이터를 요청하고 전송하지 않는 한, Redux같은 상대관리 툴의 사용이 강제된다.

결제 페이지를 생각해보자. 주문자, 받는사람에 대한 정보를 기록해야 한다. 각각 이름, 전화번호, 주소를 기록해야 하고, 배송요청 메시지나 우편번호도 받아야 한다. 결제동의도 받아야 한다. 삽시간에 입력해야할 폼이 10개에 가까워진다.

const PayForm = () => {
  const [phoneNumber, setPhoneNumber] = useState('')
  const [payerName, setPayerName] = useState('')
  const [receiverName, setReceiverName] = useState('')
  const [receiverAddress, setReceiverAddress] = useState('')
  const [zipCode, setZipCode] = useState('')
  // ... and so on...
}

Redux를 사용하는 것도 좋은 방법이다. 서비스가 복잡해지면 오히려 Redux를 사용하는 것이 차라리 유지보수를 용이하게 하는 측면도 있다. 다만, Redux를 사용하지 않고도 이런 문제를 해결해야할 경우도 있을 수 있다.

오늘은 useReducer를 사용함으로써 이런 코드를 보다 간결하게 정리해본다.

<예제코드 보러가기>

useReducer

먼저 useReducer를 간단히 정리해보자.

먼저 공식문서는 이렇게 정리하고 있다.

useStat의 대체 함수입니다. (state, action) => newState의 형태로 reducer를 받고 dispatch메서드와 짝의 형태로 현재 state를 반환합니다. (Redux에 익숙하다면 이것이 어떻게 동작하는지 여러분은 이미 알고 있을 것입니다.)

아무래도 번역투라 설명이 난해하다.

useReducer에 대해일단 알 수 있는 것을 분할해 정리해보자.

  1. useState의 대체함수이다.
  2. (state, action) ⇒ newState 형태의 함수를 인자로 받는다.
  3. dispatch와 state를 반환한다.
  4. redux와 유사하다.

사용은 보통 이런식으로 한다.

const [state, dispatch] = useReducer(reducer, initialState)

state는 dispatch로 업데이트된 최신 상태를 담고 있다.

dispatch는 상태를 업데이트 하는 액션을 트리거하는 함수다.

아래처럼 사용한다.

const [state, dispatch] = useReducer(reducer, initialState)

const onChangeText = useCallback((e: ChangeEvent<HTMLInputElement>) => {
  const { name, value } = e.target
  dispatch({
    type: ActionType.CHANGE_INPUT,
    // ...
  })
}, [])

위 코드에서 만든 onChangeText 메서드는 <input onChange={onChangeText} />와 같이 사용한다.

Reducer

그럼 대체 reducer란 뭘까?

redux를 작성해봤다면 익숙한 바로 그 reducer다.

const reducer = (prevState, action) => {
  switch(action.type) {
    case 'CHANGE_INPUT':
      return {...prevState, action.payload}
    // ...
    default:
      return {}
  }
}

reducer는 다시 prevStateaction을 인자로 받는 순수함수로 이뤄져 있다.

prevStateuseReducerreducer 함수를 인자로 넘기면 useReducer 훅이 상태 변경시 이전 상태를 인자로 전달해준다.

action의 동작해야할 내용을 담고 있는데 redux에서는 action의 타입을 문자열 상수로 선언해 사용하는 것이 일반적이다.

실제 동작하는 코드 확인하기

간단히 여러개의 입력을 다루는 예제 코드를 만들어보았다.

<예제코드 보러가기>

먼저 action의 타입과 각종 인터페이스들을 선언한다. 타입스크립트를 사용했다.

enum ActionType {
  CHANGE_INPUT = 'CHANGE_INPUT',
  //...
}

interface ActionInterface {
  type: ActionType
  name: string
  value: string
}

interface IInputState {
  name: string
  address: string
  zipcode: string
  message?: string
}

다음으로 reducer를 작성해준다.

const reducer = (prevState: IInputState, action: ActionInterface) => {
  if (action.type === ActionType.CHANGE_INPUT) {
    return {
      ...prevState,
      [action.name]: action.value,
    }
  }

  return prevState
}

이 예제는 사실 매우 단순한 예제이기 때문에 switch문을 사용하지 않고 if/return 문을 사용했다.

이제 실제 컴포넌트 코드를 작성해준다.

const Home = () => {
  const initialState = {
    name: '',
    address: '',
    zipcode: '',
    message: '',
  }

  const [resultVisible, setResultVisible] = useState(false)

  const [state, dispatch] = useReducer(reducer, initialState)

  const onChangeInput = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target
    dispatch({
      type: ActionType.CHANGE_INPUT,
      name,
      value,
    })
  }, [])

  const onClickSumbit = (e: MouseEvent) => {
    setResultVisible(true)
  }

  return (
    <section
      style={{ display: 'flex', flexDirection: 'column', width: '300px' }}
    >
      <input name="name" placeholder="받는사람" onChange={onChangeInput} />
      <input name="address" placeholder="주소" onChange={onChangeInput} />
      <input name="zipcode" placeholder="우편번호" onChange={onChangeInput} />
      <input name="message" placeholder="배송메시지" onChange={onChangeInput} />
      <button onClick={onClickSumbit}>구매하기</button>

      {resultVisible && (
        <div>
          <p>받는사람: {state.name}</p>
          <p>주소: {state.address}</p>
          <p>우편번호: {state.zipcode}</p>
          <p>배송메시지: {state.message}</p>
        </div>
      )}
    </section>
  )
}

참고로 실제 예제 repository는 next.js를 사용하고 있다.

“구매하기” 버튼을 클릭하면 입력한 내용이 화면에 렌더링되게 되어 있다.

언제 사용할까?

recoil이 나온 후로, React에서 상태관리를 할 때 복잡한 고민 없이 recoil을 사용하는 경우가 많아진 것 같다.

redux를 사용하는 경우 긴 보일러플레이트 코드를 작성해야 하는 것 때문에, 많은 개발자 사이에서 오버 엔지니어링이 아닌가 하는 의견이 분분한 경우가 있었다.

복잡한 어플리케이션이 아니라면 당연히 redux를 사용하는 것은 바람직하지 않아 보였다. 하지만 상당히 편리하게 사용가능한 recoil이 나온 후로 간단한 프로젝트에서 recoil을 사용하는 경우를 종종 보게 된다.

다만, recoil의 경우 아직까지 안정화된 상태라고 보긴 어려울 것 같다. 현재 버전은 0.7.6이며 아직 1점대 버전이 출시되지 않았다. 이런 이유에서 프로덕션 환경에서는 recoil을 사용하기 힘들다고 판단하는 엔제니어도 분명 있을 것이다.

MSA 환경에서 redux를 쓰지 않으면서 복잡한 input을 관리하고 싶다면 useReducer를 통해 관리해보는 것도 좋을 것 같다.

참고자료

https://ko.reactjs.org/docs/hooks-reference.html#usereducer

https://stackoverflow.com/questions/62681581/using-too-many-usestate-hooks-in-react-how-do-i-refactor-this

https://react.vlpt.us/basic/20-useReducer.html