복잡한 상태를 관리하는 useReducer

useState와 useReducer 뭐가 다를까?

useReducer는 state 관리를 도와준다. useState랑 비슷하지만 useState보다 복잡한 state 관리에 유용하며 더 많은 기능이 있다. 예를 들어 로그인할 때 사용되는 이메일, 비밀번호, 폼 유효 여부의 경우 서로 관련이 있는 상태이기 때문에 useReducer를 사용하기 적합하다.

여러 state들이 함께 속해있는 경우나 여러 state가 같이 바뀌는 경우 혹은 서로 관련된 경우 useState의 경우 종종 사용 및 관리가 어려워지거나 오류가 발생하기 쉽다.

 setEmailIsValid(enteredEmail.includes("@")) => enteredEmail

위 코드처럼 state를 업데이트할 때 다른 state에 의존해서 할 경우 코드가 정상적으로 동작하지 않을 수 있다. enteredEmail 업데이트가 아직 이뤄지지 않았을 수도 있기 때문이다. 함수형 업데이트를 통해 이전 값을 기반으로 업데이트하는 방법이 있지만 여기서는 사용할 수 없다. 함수형 업데이트로 가져올 수 있는 최신 값은 emailIsValid이지 enteredEmail이 아니기 때문이다. 이런 경우 useState로 두 개의 값을 객체로 묶어 한번에 관리할 수도 있겠지만, state가 더 복잡하거나 여러 가지 관련된 state가 결합된 경우라면 useReducer를 사용하는 것이 좋다.

useReducer 어떻게 사용할까?

const [state, dispatchFn] = useReducer(reducerFn, initialState, initFn);

useReducer는 useState처럼 두 개의 값이 있는 배열을 반환한다. state는 최신 state 스냅샷이고, dispatchFn는 state를 업데이트하는 함수이다. 얼핏보기에 useState와 비슷하지만 dispatchFn는 setState와 다르게 동작한다. 새로운 state 값을 설정하는 대신 actiondispatch한다. 번역해보면 action은 행동, dispatch는 전달하다 라는 뜻이다. 즉, dispatch는 해야 할 행동을 전달한다. action은 useReducer의 첫 번째 인수 리듀서 함수(reducerFn)가 사용한다.

리듀서 함수(reducerFn)는 state가 업데이트되는 방식을 지정하는 함수다. 액션이 디스패치될 때마다 (리액트로부터) 리듀서 함수가 호출된다. 호출된 리듀서 함수는 최신의 state 스냅샷과 디스패치된 액션을 가져온다. 그리고 새로 업데이트된 state를 반환한다. 리듀서 함수(reducerFn)는 렌더링 중에 실행되므로 순수 함수여야 한다. 즉, 입력 값이 같다면 결과 값도 항상 같아야 한다. 요청을 보내거나 timeout을 스케쥴링하거나 사이드 이펙트(컴포넌트 외부에 영향을 미치는 작업)을 수행해서는 안된다.

initialState는 초기 state이고, initFn(option)는 초기 state를 설정하기 위해 실행하는 초기화 함수다. initFn를 지정하지 않으면 초기 state는 initialState로 설정되고 지정하면 initFn(initialState)를 호출한 결과로 설정된다. initFn는 HTTP 리퀘스트의 결과와 같이 초기 state가 복잡한 경우에 사용한다.

useReducer 예시

// 리듀서 함수에 매개변수인 state는 최신 state의 스냅샷이다.
const emailReducer = (state, action) => {
  if (action.type === "USER_INPUT") {
    return { value: action.val, isValid: action.val.includes("@") };
  }
 
  return { value: "", isValid: false };
};
 
const Login = (props) => {
  const [emailState, dispatchEmail] = useReducer(emailReducer, {
    value: "",
    isValid: null,
  });
 
  const emailChangeHandler = (event) => {
    dispatchEmail({ type: "USER_INPUT", val: event.target.value });
  };
};

만약 useEffect를 사용하는데 의존성 배열에 담긴게 객체 자체라면 해당 객체가 변경될 때마다 useEffect가 재실행될 것이다. 아래 코드처럼 객체 구조할당을 사용해서 전체 객체 대신 특정 속성을 의존성으로 설정하면 useEffect 실행 횟수를 줄일 수 있다.

전체 코드 참고

const { isValid: emailIsValid } = emailState;
const { isValid: passwordIsValid } = passwordState;
 
useEffect(() => {
  const identifier = setTimeout(() => {
    setFormIsValid(emailIsValid && passwordIsValid);
  }, 500);
 
  return () => {
    clearTimeout(identifier);
  };
}, [emailIsValid, passwordIsValid]);

🚫 주의 🚫

만약 업데이트하려는 값이 Object.is로 비교했을 때 현재 state와 같다면, 리액트는 최적화를 위해 컴포넌트와 그 자식들을 다시 렌더링하지 않는다. Object.is는 배열이나 객체의 경우 메모리에서 동일한 객체를 참조하는지 확인하므로(= 주소값이 같은지 여부를 확인), 배열이나 객체를 직접 수정하지말고 새로운 배열이나 객체를 반환하여 값을 업데이트한다.

리액트는 state 업데이트를 일괄 처리한다. 이벤트 핸들러 내부의 모든 코드가 실행되고 set 함수를 호출한 다음에 화면을 업데이트한다. 이렇게 하는 이유는 한번의 이벤트가 일어날 때 여러 번 리렌더링되는 것을 방지하기 위함이다. 자주 쓰이진 않지만 DOM에 접근하기 위해 리액트가 화면을 더 일찍 업데이트하도록 강제해야 하는 경우 flushSync를 사용할 수 있다.

정리

  • useReducer는 useState와 비슷하게 상태 관리를 도와준다.
  • useReducer는 여러 state들이 함께 속해있는 경우나 여러 state가 같이 바뀌는 경우 혹은 서로 관련된 경우 사용한다 ex) 로그인 or 회원가입 폼
  • useReducer는 state가 업데이트되는 방식을 지정하는 리듀서 함수와 초기값, 초기화 함수를 인수로 받으며 최신 state 스냅샷과 action을 전달하는 dispatch를 반환한다.

느낀점

reducer의 의미가 감속기길래 코드를 줄여줘서 이름을 reducer로 붙인 걸까 했는데, 공식 문서를 보니 자바스크립트 reduce()에서 따서 지은 이름이라고 한다.

reduce() 메서드는 배열의 각 요소에 대해 주어진 리듀서 (reducer) 함수를 실행하고, 하나의 결과값을 반환합니다. - MDN

reduce()는 배열의 값들을 하나의 값으로 누적하는데 여기서 누적을 수행하는 함수를 리듀서 함수라고 한다. 리듀서 함수는 지금까지의 누적값과 현재 값을 가지고 다음 결과를 반환한다. 리액트의 reducer도 비슷하다. 지금까지의 state와 action을 가지고 다음 state를 반환한다. 생각도 못하고 있었는데 reduce에서 따온 이름이라고 해서 신기했다.

참고

  • React 완벽 가이드 with Redux, Next.js, TypeScript
  • React 공식 문서(useReducer, Extracting State Logic into a Reducer)
  • React