Featured image of post [React Hooks] useReducer로 복잡한 state 관리 하기

[React Hooks] useReducer로 복잡한 state 관리 하기

Section 11. Advanced: Handling Side Effects, Using Reducers & Using the Context API

Udemy의 React-The Complete Guide (incl Hooks, React Router, Redux) 강의를 바탕으로 작성된 글입니다.

 

useReducer를 소개합니다.

useReducer는 내장된 Hook입니다. state를 관리를 도와준다는 차원에서 useState와 약간 비슷합니다. 하지만 더 많은 기능이 있죠. useReducer는 복잡한 상태를 관리하는 데 특별히 사용됩니다.

우리는 다음과 같은 상황에서 useReducer의 사용을 고려해볼 수 있습니다.

  1. 같은 것을 관리하는데 관리하는 측면이 다른 경우
    • 로그인 폼 관리 state ← 결국 전체 form을 관리하지만, 그 역할이 세분화된 state
      • 아이디 input
      • 비밀번호 input
  2. 여러 state가 함께 바뀌거나 서로 연관된 경우
    • 비밀번호 관련 state ← 함께 바뀌는 state
      • 비밀번호 input
      • 비밀번호 validity
    • Form Validity Check ← 다른 state들과 연관된 state
      • 로그인 버튼 활성 여부 (아이디 validity state와 비밀번호 validity state에 의존한다.)

위와 같은 경우에서 useState를 통해 관리되는 state는 종종 사용 및 관리가 어려워지거나 오류가 발생하곤 합니다. 좋지 못하거나 비효율적인 코드가 될 수 있습니다.

 

useReducer의 구조를 살펴봅시다.

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

useState와 마찬가지로, 항상 두 개의 값이 있는 배열을 반환합니다. 따라서 구조 분해 할당(Array Destructuring)을 사용할 수 있습니다.

반환되는 두 값은 최신 state 스냅샷state를 업데이트할 수 있게 해주는 함수입니다. useState와 비슷하나, state 업데이트 함수는 다르게 동작합니다. 새로운 state 값을 설정하지 않고, useReducer의 첫번째 인수인 reducerFn이라는 액션을 디스패치합니다. reducerFn은 최신 state 스냅샷을 자동으로 가져오고 리듀서 함수 실행을 트리거하는 디스패치된 액션을 가져옵니다. 그리고 새롭게 업데이트된 state를 반환합니다.

다음 Section의 사용 예시를 보면 더 쉽게 이해할 수 있을 것입니다.

💡 useReducer는 분명 강력합니다.
하지만 항상 useReducer를 사용해야 하는 것은 아닙니다. useReducer는 useState보다 복잡하기 때문에 조금 더 설정이 필요합니다. 그렇기 때문에 대부분의 경우는 useState를 사용하는 편이 좋습니다.

 

useReducer를 사용해봅시다.

기존 코드: useState()

먼저 useReducer를 사용하기 전, useState를 통해서만 관리되고 있는 코드를 살펴봅시다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import React, { useState, useEffect } from "react";

import Card from "../UI/Card/Card";
import classes from "./Login.module.css";
import Button from "../UI/Button/Button";

const Login = (props) => {
  const [enteredEmail, setEnteredEmail] = useState("");
  const [emailIsValid, setEmailIsValid] = useState();
  const [enteredPassword, setEnteredPassword] = useState("");
  const [passwordIsValid, setPasswordIsValid] = useState();
  const [formIsValid, setFormIsValid] = useState(false);

  useEffect(() => {
    const identifier = setTimeout(() => {
      setFormIsValid(
        enteredEmail.includes("@") && enteredPassword.trim().length > 6
      );
    }, 500);

    return () => {
      clearTimeout(identifier);
    };
  }, [enteredEmail, enteredPassword]);

  const emailChangeHandler = (event) => {
    setEnteredEmail(event.target.value);
  };

  const passwordChangeHandler = (event) => {
    setEnteredPassword(event.target.value);
  };

  const validateEmailHandler = () => {
    setEmailIsValid(enteredEmail.includes("@"));
  };

  const validatePasswordHandler = () => {
    setPasswordIsValid(enteredPassword.trim().length > 6);
  };

  const submitHandler = (event) => {
    event.preventDefault();
    props.onLogin(enteredEmail, enteredPassword);
  };

  return (
    <Card className={classes.login}>
      <form onSubmit={submitHandler}>
        <div
          className={`${classes.control} ${
            emailIsValid === false ? classes.invalid : ""
          }`}
        >
          <label htmlFor="email">E-Mail</label>
          <input
            type="email"
            id="email"
            value={enteredEmail}
            onChange={emailChangeHandler}
            onBlur={validateEmailHandler}
          />
        </div>
        <div
          className={`${classes.control} ${
            passwordIsValid === false ? classes.invalid : ""
          }`}
        >
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            value={enteredPassword}
            onChange={passwordChangeHandler}
            onBlur={validatePasswordHandler}
          />
        </div>
        <div className={classes.actions}>
          <Button type="submit" className={classes.btn} disabled={!formIsValid}>
            Login
          </Button>
        </div>
      </form>
    </Card>
  );
};

export default Login;

먼저 useEffect 내부의 setFormIsValid를 살펴봅시다. setFormIsValidformIsValid 상태를 변경하는 Setter 함수입니다. 하지만 enteredEmailenteredPassword라는 다른 상태에 의존하고 있습니다. 해당 코드에서는 useEffect의 의존성 배열 안에 enteredEmailenteredPassword를 넣어 사용하고 있으므로 문제가 발생하지 않습니다. 하지만 다른 경우, 리액트가 state 업데이트를 스케쥴링하는 방식 때문에 setFormIsValid가 실행될 때 enteredEmail과 enteredPassword가 최신 스냅샷을 갖고 있지 않을 수 있습니다. (매우 드문 경우이긴 합니다.)

다음으로 validateEmailHandlervalidatePasswordHandler를 살펴보도록 합시다. 앞선 예시와 마찬가지로 emailIsValid, passwordIsValid 상태를 변경하기 위해 enteredEmailenteredPassword라는 다른 상태들을 호출하고 있습니다. 물론 이 상태들은 서로 연관되어 있습니다. 두 가지 모두 사용자가 입력한 내용에 따라 바뀌죠. 하지만 엄밀히 말하자면 이 두 가지는 서로 다른 상태들입니다. 다른 상태를 통해서 지금의 상태를 도출하는 것은 위험한 행동입니다. (대부분의 경우 제대로 동작하지만, 어떤 경우에는 작동하지 않을 수도 있습니다. ← state 업데이트 스케쥴링)

다른 상태를 기반으로 하는 상태를 업데이트하는 경우라면, useReducer를 사용하지 않고 객체로 묶어 하나의 state로 병합함으로써 이 문제를 해결할 수도 있을 것입니다. 하지만 state가 더 복잡하고 커지고 여러가지 관련된 state가 결합된 경우라면 useReducer를 고려해볼 수 있습니다.

 

useReducer()로 변경하기

우선 email 관련 상태들을 useReducer를 사용해 리팩터링해보도록 하겠습니다. 우리는 input 값과 유효성을 하나의 state로 결합하고, useReducer로 관리할 것입니다. useReducer를 사용하기 위해 import 해주고, 상태는 emailState, 디스패치 함수는 dispatchEmail로 선언해주겠습니다. useReducer는 첫 번째 매개변수로 reducerFn(리듀서 함수)를 취한다고 했었습니다. 이는 화살표 함수를 통해 사용할 수도 있지만, 이번에는 별도의 명명 함수로 사용하겠습니다. 이름은 emailReducer로 하겠습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import React, { useState, useEffect, useReducer } from "react";

const emailReducer = () => {};

const Login = (props) => {
  ...

  const [emailState, dispatchEmail] = useReducer(emailReducer);

  ...

  return (...);
};

리듀서 함수는 컴포넌트 함수 내부에서 만들어진 어떠한 데이터도 사용하지 않기 때문에 컴포넌트 함수의 범위 밖에서 선언하였습니다. 리듀서 함수는 두 개의 매개변수를 받습니다. 최신 state 스냅샷과 디스패치된 액션입니다. 이번 예시에서는 input값과 유효성을 하나의 state로 결합한다고 했으니 {value: ..., isValid: ...}의 형태가 될 것입니다. useReducer를 선언한 곳에 초기값을 위와 같은 형태로 추가해주겠습니다. 이제 useState로 선언된 enteredEmail 대신, useReducer로 선언된 emailState.value를 사용할 수 있으니 모두 바꿔주도록 하겠습니다. 그리고 더이상 enteredEmail은 사용하지 않으니 useState 선언부를 지워주도록 하겠습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import React, { useState, useEffect, useReducer } from "react";

const emailReducer = (state, action) => {
  return { value: "", isValid: false };
};

const Login = (props) => {
  ...

  const [emailState, dispatchEmail] = useReducer(emailReducer, {
    value: "",
    isValid: null,
  });

  ...

  return (...);
};

다음은 handler에서 처리하는 로직을 reducerFn 안의 액션으로 넣어줄 것입니다. email 관련 handler가 2개이므로, 액션도 2개로 설계할 수 있겠네요. 입력값을 최신화하는 USER_INPUT과 입력값을 검증하는 INPUT_BLUR를 추가해주겠습니다. 우선 handler 안의 기존 useState의 Setter 함수들을 dispatch 함수로 변경해 줄 것입니다. 원래 전달하던 인자는 그대로 전달해주겠습니다. dispatch 함수에는 전달할 값과 함께 어떤 액션을 수행할 것인지를 지정해주어야 합니다. emailChangeHandler 내부를 dispatchEmail({ type: "USER_INPUT", val: event.target.value });의 형태로 작성해주겠습니다. type은 우리가 수행할 액션이고, val은 전달할 값입니다. validate 부분은 특별히 전달해야 할 인자가 없습니다. useReducer에서 관리해주는 state를 그대로 가져와서 사용하면 되기 때문에 액션의 type만 지정해주도록 하겠습니다. dispatchEmail({ type: "INPUT_BLUR" });

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React, { useState, useEffect, useReducer } from "react";

const emailReducer = (state, action) => {
  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 });
  };

  const validateEmailHandler = () => {
    dispatchEmail({ type: "INPUT_BLUR" });
  };

  return (...);
};

reducerFn 내부에 로직을 작성해주겠습니다. 먼저 emailReducer는 로직을 처리하기 전에 어떤 액션이 디스패치 된 것인지 검사해야 합니다. 비교적 간단한 state이기 때문에 if 조건문으로 분기처리를 해주겠습니다. 액션은 우리가 type으로 디스패치했기 때문에 action.type을 통해서 접근할 수 있습니다. if (action.type === "...") { return { new state } }
USER_INPUT은 전달받은 값으로 value를 변경해줘야 하므로 action.val로 접근하여 새로운 state를 반환해줍니다. isValid에는 action.val에 기존의 검증 로직을 추가하겠습니다. 하지만 INPUT_BLUR는 다릅니다. 값을 변경하는 것이 아니라 가장 최근에 변경된 스냅샷을 받아 검사해야 합니다. 앞서 reducerFn은 최신 스냅샷의 state와 디스패치된 action을 인자로 받는다고 했었습니다. 새로 전달받은 값이 아닌, 기존 state의 최신 스냅샷에 접근하는 것은 reducerFn의 매개변수 state를 통해서 가능합니다. state의 양식이 { value, isValid }였으므로 state.value에 검증 로직을 추가하여 반환합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import React, { useState, useEffect, useReducer } from "react";

const emailReducer = (state, action) => {
  if (action.type === "USER_INPUT") {
    return { value: action.val, isValid: action.val.includes("@") };
  } else if (action.type === "INPUT_BLUR") {
    return { value: state.value, isValid: state.value.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 });
  };

  const validateEmailHandler = () => {
    dispatchEmail({ type: "INPUT_BLUR" });
  };

  return (...);
};

💡 if 조건분기가 별로라면?
이번 예시에서는 액션이 2종류이기 때문에 간단하게 if로 처리해주었습니다. 만약 액션의 가지 수가 많아지고 복잡해진다면 switch를 사용해도 좋습니다.

 

새로 작성한 코드: useReducer()

이렇게 email을 useReducer로 변경해보았습니다. password도 비슷한 방식으로 변경해주면 됩니다. email과 password, 각각의 useReducer를 하나로 합쳐 전체 Form state를 관리하도록 만들수도 있습니다.
최종 코드입니다.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import React, { useState, useEffect, useReducer } from "react";

import Card from "../UI/Card/Card";
import classes from "./Login.module.css";
import Button from "../UI/Button/Button";

const emailReducer = (state, action) => {
  if (action.type === "USER_INPUT") {
    return { value: action.val, isValid: action.val.includes("@") };
  } else if (action.type === "INPUT_BLUR") {
    return { value: state.value, isValid: state.value.includes("@") };
  }
  return { value: "", isValid: false };
};

const passwordReducer = (state, action) => {
  if (action.type === "USER_INPUT") {
    return { value: action.val, isValid: action.val.trim().length > 6 };
  } else if (action.type === "INPUT_BLUR") {
    return { value: state.value, isValid: state.value.trim().length > 6 };
  }
  return { value: "", isValid: false };
};

const Login = (props) => {
  const [formIsValid, setFormIsValid] = useState(false);

  const [emailState, dispatchEmail] = useReducer(emailReducer, {
    value: "",
    isValid: null,
  });

  const [passwordState, dispatchPassword] = useReducer(passwordReducer, {
    value: "",
    isValid: null,
  });

  const { isValid: emailIsValid } = emailState;
  const { isValid: passwordIsValid } = passwordState;

  useEffect(() => {
    const identifier = setTimeout(() => {
      setFormIsValid(emailIsValid && passwordIsValid);
    }, 500);

    return () => {
      clearTimeout(identifier);
    };
  }, [emailIsValid, passwordIsValid]);

  const emailChangeHandler = (event) => {
    dispatchEmail({ type: "USER_INPUT", val: event.target.value });
  };

  const passwordChangeHandler = (event) => {
    dispatchPassword({ type: "USER_INPUT", val: event.target.value });
  };

  const validateEmailHandler = () => {
    dispatchEmail({ type: "INPUT_BLUR" });
  };

  const validatePasswordHandler = () => {
    dispatchPassword({ type: "INPUT_BLUR" });
  };

  const submitHandler = (event) => {
    event.preventDefault();
    props.onLogin(emailState.value, passwordState.value);
  };

  return (
    <Card className={classes.login}>
      <form onSubmit={submitHandler}>
        <div
          className={`${classes.control} ${
            emailIsValid === false ? classes.invalid : ""
          }`}
        >
          <label htmlFor="email">E-Mail</label>
          <input
            type="email"
            id="email"
            value={emailState.value}
            onChange={emailChangeHandler}
            onBlur={validateEmailHandler}
          />
        </div>
        <div
          className={`${classes.control} ${
            passwordIsValid === false ? classes.invalid : ""
          }`}
        >
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            value={passwordState.value}
            onChange={passwordChangeHandler}
            onBlur={validatePasswordHandler}
          />
        </div>
        <div className={classes.actions}>
          <Button type="submit" className={classes.btn} disabled={!formIsValid}>
            Login
          </Button>
        </div>
      </form>
    </Card>
  );
};

export default Login;

💡 useEffect의 의존성 배열에 useReducer state 추가하기
예시처럼 구조분해할당으로 원하는 데이터를 따로 변수로 빼서 추가해줘도 좋지만, 직접 접근하여 추가하는 방법도 괜찮습니다. useEffect(() => {...}, [emailState.valid, passwordState.valid])

 

useState() vs useReducer()

마지막으로 useState를 반드시 사용해야 하는 때는 언제인지, useReducer는 언제 사용하는 것이 좋은지 다시 정리해보도록 하겠습니다.

useState를 사용하면 너무 번거로운 경우, 많은 일들을 처리해야하는 경우, 관련 state 스냅샷들이 서로 독립적이고 같이 업데이트가 잘 안되는 경우에 useReducer를 사용하고 싶을 것입니다.

useState는 주요 state 관리 도구입니다. 개별 state 및 데이터들을 다루기에 적합하고, 간단한 state에 사용할 수 있습니다. state 업데이트가 쉽고, 몇 종류 안되는 경우에 적합합니다.

state로서의 객체가 있거나, 복잡한 state가 있다면 useReducer를 고려할 수 있습니다. Reducer 함수를 통해 복잡한 state 업데이트 로직을 구현할 수 있습니다. 연관된 state 조각들로 구성된 데이터를 다루는 경우에 useReducer는 도움이 될 수 있습니다.

항상 useReducer를 사용하는 것은 옳지 않습니다. 단순한 state의 경우 useReducer는 과할 수 있기 때문입니다.

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus