[React] 상태를 관리하는 또다른방법, useReducer

2024. 7. 1. 00:05dev

리액트 공식문서의 Learn 파트를 읽고 개인적으로 정리하는글 ✍️
https://ko.react.dev/learn/extracting-state-logic-into-a-reducer


INDEX.  

1. reducer를 사용해야하는 이유
2. useState를 useReducer로 리팩토링하는 방법
3. reducer를 잘 작성하는 방법 
4. 내 프로젝트 코드에 적용시켜보기 
 

1. reducer를 사용해야하는 이유

reducer가 필요해지는 순간은 다음과같은 상황들이다. 
 
예를들어, 하나의 state를 3가지의 상황에서 업데이트하는 코드가 있다.
아래의 App 컴포넌트에서는 세가지 핸들러에서 각각 state를 업데이트하고있는데, 이런게 점점 많아지면 상태를 업데이트하는 경우들을 한눈에 파악하기 어려워질수가 있다. 

export default function App() {
  const [state, setState] = React.useState(initialData);

  function 핸들러A() { setState(변경할 state들); }
  function 핸들러B() { setState(변경할 state들); }
  function 핸들러C() { setState(변경할 state들); }
    
  return (
    <어떤자식컴포넌트 
      onClick={핸들러A}
      onChange={핸들러B}
      onDelete={핸들러C}
    />
  );
}

 
이때 리액트훅에서 제공하는 useReducer를 사용하면,
여기저기 흩어져있는 state 로직을 컴포넌트 외부 단일함수로 옮길수가있고, 우린 이 함수를 reducer라고 부르기로 했다.
 

2. useState를 useReducer 로 리팩토링하는 방법

useReducer 도 결국 상태를 관리하는 함수일뿐이다. 처음부터 useReducer로 작성하는경우는 드물지않을까? 컴포넌트가 점점 복잡해지면서 상태관리 로직들이 가독성이 떨어진다고 판단이 되면 그때 리팩토링을 진행하는게 좋을것같다. 위와같이 똑같은 상태업데이트를 세번이상 반복하는경우에 말이다. 
 
useState를 useReducer로 마이그레이션 해보자. 총 세가지 단계가 필요하다. 
 
Step 1) setState -> action 전달 로 변경
모든 state 설정 로직을 제거하고, reducer 함수에게 action을 전달하는 방식으로 변경해야한다. 

export default function App() {
  const [state, setState] = React.useState(initialData);

  function 핸들러A() { // action A를 전달 }
  function 핸들러B() { // action B를 전달 }
  function 핸들러C() { // action C를 전달 }
   
  return (
    <어떤자식컴포넌트 
      onClick={핸들러A}
      onChange={핸들러B}
      onDelete={핸들러C}
    />
  );
}

 
이때 사용자의 action을 전달하는 방법은 dispatch라는 함수에 전달하기로 약속되어있다. 이제 우리는 핸들러함수에서 직접 상태변경을 하지 않게되었다! 
핸들러함수에서는 dispatch 함수에 전달할 액션객체만 작성하고, dispatch는 해당 액션을 reducer에게 전달만 해주는 배달원 역할. 

export default function App() {
  // const [state, setState] = React.useState(initialData);

  function 핸들러A() { 
    dispatch({
      type: 'A를 하십시오',
      // 그외 변경될 상태들 
    })
  }
  function 핸들러B() { 
    dispatch({
      type: 'B를 하십시오',
      // 그외 변경될 상태들 
    })
  }
  function 핸들러C() { 
    dispatch({
      type: 'C를 하십시오',
      // 그외 변경될 상태들 
    })
  }
    
  return (
    <어떤자식컴포넌트 
      onClick={핸들러A}
      onChange={핸들러B}
      onDelete={핸들러C}
    />
  );
}

 
기존의 세군데에서 setState를 각각 지정했을때보다, 사용자가 무슨 의도로 핸들러함수를 트리거했는지가 명확하게 드러난다. 
 
Step 2)  reducer 함수 작성 
이 함수는 현재의 state와 action객체를 인자로 받고, 변경될 다음상태를 반환하는 함수이다. 위에서 전달하는 action 객체들이 여기에 들어오게된다. 

function myReducer(state, action) {
  switch (action.type) {
    case 'A를 하십시오': {
      return 변경할 state들; 
    }
    case 'B를 하십시오': {
      return 변경할 state들; 
    }
    case 'C를 하십시오': {
      return 변경할 state들; 
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

 
 Step 3) 컴포넌트에 reducer 연결하기 
이제 마지막.. App 컴포넌트에서 myReducer를 사용해보자. 

export default function App() {
  const [state, dispatch] = React.useReducer(myReducer, initialData);

  function 핸들러A() { 
    dispatch({
      type: 'A를 하십시오',
      // 그외 변경될 상태들 
    })
  }
  function 핸들러B() { 
    dispatch({
      type: 'B를 하십시오',
      // 그외 변경될 상태들 
    })
  }
  function 핸들러C() { 
    dispatch({
      type: 'C를 하십시오',
      // 그외 변경될 상태들 
    })
  }
    
  return (
    <어떤자식컴포넌트 
      onClick={핸들러A}
      onChange={핸들러B}
      onDelete={핸들러C}
    />
  );
}

function myReducer(state, action) {
  switch (action.type) {
    case 'A를 하십시오': {
      return 변경할 state들; 
    }
    case 'B를 하십시오': {
      return 변경할 state들; 
    }
    case 'C를 하십시오': {
      return 변경할 state들; 
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

 
이제 useState 대신에 useReducer로 상태를 관리한다. 

상태업데이트 역할 - myReducer 
어떤 상태변경을 할건지 알려주는 역할 - dispatch 

 
이렇게 관심사를 분리해서 핸들러함수에서는 더이상 상태업데이트를 직접적으로 하지않게 되었다. 

3. reducer를 잘 작성하는 방법 

a) Reducer는 반드시 순수해야한다.
이건 useState 와 마찬가지. 둘다 컴포넌트의 상태를 관리하는 함수이므로 입력값이 같으면 결과값도 항상 동일해야하는 순수함수로 작성되어야한다. 
b) 각 Action은 데이터 안에서 여러 변경들이 있더라도 하나의 사용자 상호작용을 의미해야한다. 
 

4. 프로젝트에 적용시켜보기 

useState로 작성된 기존 코드 

import React from 'react';

import { validate } from '../../util/validators';
import './Input.css';

const Input = (props) => {
    const [inputState, setInputState] = React.useState({
        value: props.initialValue || '',
        isTouched: false,
        isValid: props.valid || false,
    });

    const { id, onInput, validators } = props;
    const { value, isValid } = inputState;

    React.useEffect(() => {
        onInput(id, value, isValid);
    }, [id, value, isValid, onInput]);

    const changeHandler = (event) => {
        setInputState({
            ...inputState,
            value: event.target.value,
            isValid: validate(event.target.value, validators),
        });
    };

    const touchHandler = () => {
        setInputState({
            ...inputState,
            isTouched: true,
        });
    };

    const element =
        props.element === 'input' ? (
            <input
                id={props.id}
                type={props.type}
                placeholder={props.placeholder}
                onChange={changeHandler}
                onBlur={touchHandler} // 포커스 잃었을때 발생하는 이벤트
                value={inputState.value}
            />
        ) : (
            <textarea
                id={props.id}
                rows={props.rows || 3}
                onChange={changeHandler}
                onBlur={touchHandler}
                value={inputState.value}
            />
        );

    return (
        <div
            className={`form-control ${
                !inputState.isValid &&
                inputState.isTouched &&
                'form-control--invalid'
            }`}
        >
            <label htmlFor={props.id}>{props.label}</label>
            {element}
            {!inputState.isValid && inputState.isTouched && (
                <p>{props.errorText}</p>
            )}
        </div>
    );
};

export default Input;

 
이 컴포넌트는 공통적으로 사용할 input 컴포넌트를 만든것이고, state에 inputState라는 객체를 보유하고있다. 
총 두가지 핸들러함수를 통해 inputState 값을 변경하고있다. useReducer 로 마이그레이션 해보자. 
 
useReducer로 마이그레이션 
먼저, 변경할 두가지 이벤트핸들러 함수에서 상태변경 대신에 지정할 action부터 정했다.  
- 사용자가 input 내용을 변경할때마다 호출되는 changeHandler.  -> action type : 'CHANGE' 
- 사용자가 input 엘리먼트에서 포커스를 잃을때 호출되는 touchHandler.  -> action type : 'TOUCH' 
 
그리고나서 inputReducer라는 이름의 리듀서 함수를 정의해주고, Input 컴포넌트와 연결해주었다. 

import React from 'react';

import { validate } from '../../util/validators';
import './Input.css';

const inputReducer = (state, action) => {
    switch (action.type) {
        case 'CHANGE':
            return {
                ...state,
                value: action.val,
                isValid: validate(action.val, action.validators),
            };
        case 'TOUCH': {
            return {
                ...state,
                isTouched: true,
            };
        }
        default:
            return state;
    }
};

const Input = (props) => {
    const [inputState, dispatch] = React.useReducer(inputReducer, {
        value: props.initialValue || '',
        isTouched: false,
        isValid: props.valid || false,
    });

    const { id, onInput } = props;
    const { value, isValid } = inputState;

    React.useEffect(() => {
        onInput(id, value, isValid);
    }, [id, value, isValid, onInput]);

    const changeHandler = (event) => {
        dispatch({
            type: 'CHANGE',
            val: event.target.value,
            validators: props.validators,
        });
    };

    const touchHandler = () => {
        dispatch({
            type: 'TOUCH',
        });
    };

    const element =
        props.element === 'input' ? (
            <input
                id={props.id}
                type={props.type}
                placeholder={props.placeholder}
                onChange={changeHandler}
                onBlur={touchHandler}
                value={inputState.value}
            />
        ) : (
            <textarea
                id={props.id}
                rows={props.rows || 3}
                onChange={changeHandler}
                onBlur={touchHandler}
                value={inputState.value}
            />
        );

    return (
        <div
            className={`form-control ${
                !inputState.isValid &&
                inputState.isTouched &&
                'form-control--invalid'
            }`}
        >
            <label htmlFor={props.id}>{props.label}</label>
            {element}
            {!inputState.isValid && inputState.isTouched && (
                <p>{props.errorText}</p>
            )}
        </div>
    );
};

export default Input;

 
그렇게 복잡한 컴포넌트는 아니였지만, 실제 상태변경은 리듀서에 해주니까 관심사가 분리되고, 각 이벤트핸들러가 어떤 역할을 수행하는 함수인지 로직을 읽기가 더 명확해졌다. 
 

결론. 

useReducer는 간단한 상태관리에는 오히려 더 코드가 복잡해질수도 있다. 리듀서라는 함수도 추가로 만들어야하고 아무래도 관리포인트가 늘어나는거니까. 다른 상태 라이브러리를 도입하기 전이거나, useState 가지고는 안될거같은데..! 라고 생각이들면 사용해보자.