[React] useReducer()에 대하여(ft. 회원가입 폼 구현)

박기영·2023년 1월 18일
1

React

목록 보기
22/32

하나의 컴포넌트에서 관리해야할 state가 많아질 경우, 일일이 useState()를 사용할 수도 있고,
useReducer()를 사용해 한번에 관리하는 방법을 사용할 수 있다.

필자가 지금까지 프로젝트에서 겪어본 것 중, useState를 가장 많이 사용한 곳은
회원 가입 폼을 구현하는 곳이었다.

아래는 대략적인 회원 가입 폼 컴포넌트를 만들어 본 것이다.

import React, { useEffect, useState } from "react";

export default function App() {
  // 이메일, 이메일 유효성
  const [email, setEmail] = useState("");
  const [emailValid, setEmailValid] = useState(false);

  // 비밀번호, 비밀번호 유효성
  const [password, setPassword] = useState("");
  const [passwordValid, setPasswordValid] = useState(false);

  // 비밀번호 검증, 비밀번호 검증 유효성
  const [passwordCheck, setPasswordCheck] = useState("");
  const [passwordCheckValid, setPasswordCheckValid] = useState(false);

  // 전체 form 유효성
  const [formValid, setFormValid] = useState(false);

  const emailChangeHandler = (e) => {
    setEmail(e.target.value);
  };

  const passwordChangeHandler = (e) => {
    setPassword(e.target.value);
  };

  const passwordCheckChangeHandler = (e) => {
    setPasswordCheck(e.target.value);
  };

  // setEmail 동기 처리
  useEffect(() => {
    setEmail(email);

    if (email.length > 5) {
      setEmailValid(true);
    }
  }, [email]);

  // setPassword 동기 처리
  useEffect(() => {
    setPassword(password);

    if (password.length > 5) {
      setPasswordValid(true);
    }
  }, [password]);

  // setPasswordCheck 동기 처리
  useEffect(() => {
    if (passwordCheck.length === 0) {
      return;
    }

    setPasswordCheck(passwordCheck);

    if (passwordCheck === password) {
      setPasswordCheckValid(true);
    } else {
      setPasswordCheckValid(false);
    }
  }, [passwordCheck]);

  // 각각의 유효성 값 동기 처리
  useEffect(() => {
    if (emailValid && passwordValid && passwordCheckValid) {
      setFormValid(true);
    } else {
      setFormValid(false);
    }
  }, [emailValid, passwordValid, passwordCheckValid]);

  // submit
  const submitHandler = (e) => {
    e.preventDefault();

    console.log(email, password, passwordCheck);
  };

  return (
    <form
      onSubmit={submitHandler}
      style={{ display: "flex", flexDirection: "column" }}
    >
      <label>E-Mail</label>
      <input type="email" value={email} onChange={emailChangeHandler} />

      <label>Password</label>
      <input
        type="password"
        value={password}
        onChange={passwordChangeHandler}
      />

      <label>Password-Check</label>
      <input
        type="password"
        value={passwordCheck}
        onChange={passwordCheckChangeHandler}
      />

      <button disabled={!formValid}>Sign Up</button>
    </form>
  );
}

정말 기본적인 구현만 했을 뿐인데, 이만큼이나 코드가 길다.
useState가 관리해야하는 값들이 많은데,
원활한 처리를 위해 동기 처리까지 해주다보니 useEffect까지 늘어나버렸다.

이를 효과적으로 관리할 수 있는 방법이 없을까...?

useReducer는 언제 사용할까?

An alternative to useState. Accepts a reducer of type (state, action) => newState, and returns the current state paired with a dispatch method. (If you’re familiar with Redux, you already know how this works.)
useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one. useReducer also lets you optimize performance for components that trigger deep updates because you can pass dispatch down instead of callbacks.
- React 공식 docs -

useState의 대체 함수입니다. (state, action) => newState의 형태로 reducer를 받고 dispatch 메서드와 짝의 형태로 현재 state를 반환합니다. (Redux에 익숙하다면 이것이 어떻게 동작하는지 여러분은 이미 알고 있을 것입니다.)
다수의 하윗값을 포함하는 복잡한 정적 로직을 만드는 경우나 다음 state가 이전 state에 의존적인 경우에 보통 useState보다 useReducer를 선호합니다. 또한 useReducer는 자세한 업데이트를 트리거 하는 컴포넌트의 성능을 최적화할 수 있게 하는데, 이것은 콜백 대신 dispatch를 전달 할 수 있기 때문입니다.
- React 공식 docs -

useReducer는 다수의 useState를 사용해야하는 상황이나,
state가 이전의 state에 의존적인 경우에 사용한다고 한다.

문법

const [state, dispatch] = useReducer(reducer, initialArg, init);

우선 인자를 살펴보자.

첫 번째 인자로는 reducer가 있다. 공식 문서 설명에도 있듯이 Redux를 사용했다면 익숙할 것이다.
(state, action) => newState의 형태로 state를 업데이트한다.
따라서, reducer에 들어갈 업데이트 함수를 작성해줘야한다.

두 번째 인자로는 initialArg가 있다. 이름에서부터 알 수 있듯, state의 초기값을 설정하는 것이다.

세 번째 인자로는 init이 있다. 이 또한 이름에서부터 초기화 기능을 담당하는 것을 알 수 있다.
따라서, init에 들어갈 초기화 함수를 작성해줘야한다.

특징

공식 문서에서는 useReducer의 몇 가지의 특징을 명시해놨다.

dispatch의 불변성

React guarantees that dispatch function identity is stable and won’t change on re-renders. This is why it’s safe to omit from the useEffect or useCallback dependency list.
- React 공식 docs -

React는 dispatch 함수의 동일성이 안정적이고 리렌더링 시에도 변경되지 않으리라는 것을 보장합니다. 이것이 useEffect나 useCallback 의존성 목록에 이 함수를 포함하지 않아도 괜찮은 이유입니다.
- React 공식 docs -

dispatch는 리렌더링 시에도 변하지 않음으로 최적화를 해주지 않아도 된다.

dispatch의 회피

If you return the same value from a Reducer Hook as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)
- React 공식 docs -

Reducer Hook에서 현재 state와 같은 값을 반환하는 경우 React는 자식을 리렌더링하거나 effect를 발생하지 않고 이것들을 회피할 것입니다. (React는 Object.is 비교 알고리즘을 사용합니다.)
- React 공식 docs -

reducer가 같은 값의 state를 반환한다면 리렌더링을 유발하지 않는다는 것이다.
이는 React의 작동 원리와 관련된 것으로 보여서, useReducer만의 특징인지는 잘 모르겠다.

Object.is() 메서드는 여기를 참고해주세요!

초기값 명시 or 초기화 지연

공식 문서에서는 초기값 설정으로 두 가지 방법을 제시한다.

const [state, dispatch] = useReducer(
  reducer,
  {count: initialCount}
);

첫 번째 방법은 두 번째 인자에 초기 state를 전달하는 것이다.
즉, 초기값을 명시(Specifying the initial state)하는 것이다.

이 때,

React doesn’t use the state = initialState argument convention popularized by Redux. The initial value sometimes needs to depend on props and so is specified from the Hook call instead. If you feel strongly about this, you can call useReducer(reducer, undefined, reducer) to emulate the Redux behavior, but it’s not encouraged.
- React 공식 docs -

React에서는 Reducer의 인자로써 state = initialState와 같은 초기값을 나타내는, Redux에서는 보편화된 관습을 사용하지 않습니다. 때때로 초기값은 props에 의존할 필요가 있어 Hook 호출에서 지정되기도 합니다. 초기값을 나타내는 것이 정말 필요하다면 useReducer(reducer, undefined, reducer)를 호출하는 방법으로 Redux를 모방할 수는 있겠지만, 이 방법을 권장하지는 않습니다.
- React 공식 docs -

useReducer(reducer, undefined, reducer)와 같은 형태로의 사용은 지양하라고 한다.
사실, 와닿지는 않는다. 필자는 애초에 이렇게 사용할 생각조차 안 했다..

두 번째 방법은 초기값을 지연(Lazy initialization)해서 생성하는 것이다.
이를 위해서는 init 함수를 세 번째 인자로 전달하며, 초기 stateinit(initialArg)에 설정된다.
아래 예시를 보자.

function init(initialCount) {
  return {count: initialCount};
}

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'reset':
      return init(action.payload);
    default:
      throw new Error();
  }
}

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);
  return (
    <>
      Count: {state.count}
      <button
        onClick={() => dispatch({type: 'reset', payload: initialCount})}>
        Reset
      </button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

버튼 이름이 reset이라는 점이 이해를 도와준다.
Reset buttondispatch를 잘 보자.
props로 받아온 초기값을 payload로 전달하며,
reducer에서는 이를 init 함수에 전달하여, state를 초기값으로 되돌린다.

이런 방법은 초기 state를 계산하는 로직을 reducer 외부로 추출할 수 있게 해준다.
또한, 어떤 행동에 대한 대응으로 나중에 state를 재설정하는 데에도 유용하다.

useReducer()로 회원 가입 폼 만들기

이제 맨 위에서 작성했던 회원 가입 폼을 useReducer를 활용하는 것으로 변경해보자.

import React, { useEffect, useReducer } from "react";

// 입력값 유효성 판단을 하나의 함수에 넣어놨다
function validate(inputType, value, password = null) {
  let isValid = false;

  if (inputType === "email") {
    if (value.length > 5) {
      isValid = true;
    }

    return isValid;
  }

  if (inputType === "password") {
    if (value.length > 5) {
      isValid = true;
    }

    return isValid;
  }

  if (inputType === "passwordCheck") {
    if (value === password) {
      isValid = true;
    }

    return isValid;
  }
}

function reducer(state, action) {
  switch (action.type) {
    // 입력값 변경 시 action
    case "INPUT_CHANGE":
      // 초기 state에 넣어놨던 값들 하나하나가 stateId로 사용된다.
      // email, password, passwordCheck, formValid
      for (const stateId in state) {
        if (stateId !== action.id) {
          continue;
        }

        return {
          ...state,
          [action.id]: {
            ...[action.id],
            value: action.value,
            isValid:
              action.id === "passwordCheck"
                ? validate(action.id, action.value, state.password.value)
                : validate(action.id, action.value)
          }
        };
      }

    // form 전체 유효성 검증시 사용할 action
    case "FORM_VALIDATE":
      let formIsValid = false;

      if (
        state.email.isValid &&
        state.password.isValid &&
        state.passwordCheck.isValid
      ) {
        formIsValid = true;
      }

      return {
        ...state,
        formValid: formIsValid
      };

    // password와 passwordCheck가 서로 다른 경우 form 유효성 검증을 위한 action
    case "PASSWORD_CROSS_CHECK":
      let passwordCrossCheck = false;

      if (action.value === state.passwordCheck.value) {
        passwordCrossCheck = true;
      }

      return {
        ...state,
        passwordCheck: {
          value: state.passwordCheck.value,
          isValid: passwordCrossCheck
        },
        formValid: passwordCrossCheck
      };

    default:
      return state;
  }
}

export default function App() {
  // state를 한 번에 모아서 관리한다!
  const [state, dispatch] = useReducer(reducer, {
    email: {
      value: "",
      isValid: false
    },
    password: {
      value: "",
      isValid: false
    },
    passwordCheck: {
      value: "",
      isValid: false
    },
    formValid: false
  });

  const emailChangeHandler = (e) => {
    // dispatch를 통해 특정 type의 action을 실행하여 state를 관리한다.
    dispatch({ type: "INPUT_CHANGE", id: "email", value: e.target.value });
  };

  const passwordChangeHandler = (e) => {
    dispatch({ type: "INPUT_CHANGE", id: "password", value: e.target.value });
  };

  const passwordCheckChangeHandler = (e) => {
    dispatch({
      type: "INPUT_CHANGE",
      id: "passwordCheck",
      value: e.target.value
    });
  };

  useEffect(() => {
    dispatch({ type: "FORM_VALIDATE" });
  }, [
    state.email.isValid,
    state.password.isValid,
    state.passwordCheck.isValid
  ]);

  useEffect(() => {
    dispatch({
      type: "PASSWORD_CROSS_CHECK",
      value: state.password.value
    });
  }, [state.password.value]);

  const submitHandler = (e) => {
    e.preventDefault();

    console.log("submited state", state);
  };

  return (
    <form
      onSubmit={submitHandler}
      style={{ display: "flex", flexDirection: "column" }}
    >
      <label>E-Mail</label>
      <input
        type="email"
        value={state.email.value}
        onChange={emailChangeHandler}
      />

      <label>Password</label>
      <input
        type="password"
        value={state.password.value}
        onChange={passwordChangeHandler}
      />

      <label>Password-Check</label>
      <input
        type="password"
        value={state.passwordCheck.valud}
        onChange={passwordCheckChangeHandler}
      />

      <button disabled={!state.formValid}>Sign Up</button>
    </form>
  );
}

사실...더 길어졌다 ㅎㅎ...
각각의 입력값의 유효성 검증을 별도의 함수로 빼내고, 리듀서를 만들고 하니 점점 길어졌다.
그러나 코드의 길이보다는 여러 state의 종합적인 관리에 중점을 두는 것이므로, 깔끔해보인다.
컴포넌트 외부의 함수가 늘어난 반면, 내부의 코드는 훨씬 줄어들었다.

validate()의 경우에는 별도의 폴더를 만들어서 관리하면 더 깔끔해질 것 같다.

필자의 프로젝트는 회원 가입 폼과 로그인 폼의 기능이 거의 동일한 경우가 많은데,
이런 경우 커스텀 훅(ex. useForm())을 만들어 각각의 폼에서 불러와 사용하기도 한다.

회원 가입 폼에서는 입력된 정보들의 유효성을 검사해 정확한 양식의 유저 정보를 만드는게 목적이고,
로그인 폼에서는 입력된 정보를 서버에 보내 DB 내 존재하는 유저인지 판단하는게 목적이다.

두 개의 폼이 비슷하다는 가정하에, 로그인 폼에서는 INPUT_CHANGE 액션만 사용할 것 같다.
즉, 같은 reducer()를 사용하되, 다른 초기값을 설정하여, 필요한 데이터에만 state를 활용할 것 같다.
너무 다른 경우라면 분기 처리를 통해 다른 로직으로 작동하게 하는 것도 방법일 수 있겠다.

참고 자료

React 공식 한국어 docs - useReducer()
React 공식 영어 docs - useReducer()

profile
나를 믿는 사람들을, 실망시키지 않도록

0개의 댓글