React의 setState() 제대로 사용하기

최근 리액트 코드를 짜다가 setState()가 내 예상대로 동작하지 않는 두 가지 상황을 경험 하였다. 첫 번째 상황은 동일한 state를 변경하는 setState()를 연속적으로 사용하였을 때 이고, 두 번째 상황은 setState()를 실행한 뒤에 곧 바로 api 호출을 보냈을 때 이다. 두 경우 모두 setState()가 비동기 로 동작하는 것이 원인 이었는데 문제 상황을 다시 살펴보며 어떻게 해결 하였는지 정리해보려고 한다.


1. 동일한 state를 변경하는 setState()를 연속적으로 사용하였을 때

최종-화면

최종 코드 실행 화면


패스트푸드점에서 사이드 메뉴 주문을 입력 받아야 하는 상황을 상상해보자. 아래의 경우를 고려하여 코드를 작성해 보았다.

  • 1) 여러 개의 메뉴를 주문할 수 있기 때문에 클릭하면 주문에 추가된다.
  • 2) 이미 주문에 포함된 메뉴를 다시 클릭하면 삭제된다.
  • 3) ‘선택하지 않음’을 클릭하면 주문한 모든 메뉴가 삭제되고, ‘선택하지 않음’이 추가된다.
  • 4) ‘선택하지 않음’이 클릭된 상황에서 다른 메뉴를 클릭하면, ‘선택하지 않음’이 삭제되고, 클릭한 메뉴가 주문에 추가된다.
export default function App() {
  const sideMenus = [
    "감자 튀김 🍟",
    "콜라 🥤",
    "애플 파이 🥧",
    "소프트 아이스크림 🍦",
    "선택하지 않음"
  ];
  const [orders, setOrders] = useState([]);

  const onClickHandler = selectedItem => {
    if (selectedItem === "선택하지 않음") {
      setOrders([]);
    }

    if (orders.includes(selectedItem)) {
      setOrders(orders.filter(order => order !== selectedItem));
      return;
    }

    if (orders.includes("선택하지 않음")) {
      setOrders(orders.filter(order => order !== "선택하지 않음"));
    }
    setOrders([...orders, selectedItem]);
  };

  return (
    <div className="App">
      <h3>사이드 메뉴를 선택하세요.</h3>
      <ul className="menu-group">
        {sideMenus.map((sideMenu, idx) => (
          <li
            className={
              orders.find(order => order === sideMenu)
                ? "menu-item active"
                : "menu-item"
            }
            onClick={() => onClickHandler(sideMenu)}
            key={idx}
          >
            {sideMenu}
          </li>
        ))}
      </ul>
    </div>
  );
}

직접 코드를 실행하여 모든 케이스에서 정상적으로 동작하는지 확인해보면 세 번째와 네 번째의 케이스에서 원하는대로 동작하지 않은 것을 알 수 있다.


세 번째 케이스

  • ‘선택하지 않음’을 클릭하면 주문한 모든 메뉴가 삭제되고, ‘선택하지 않음’이 추가된다.의 상황

원하는 결과

  • setOrder([]) 로 배열을 초기화 하고, setOrders([...orders, selectedItem]);로 ‘선택하지 않음’이 들어간다.

실제 결과

  • 배열이 초기화 되지 않고, ‘선택하지 않음’이 추가된다.
    문제상황1

네 번째 케이스

  • ‘선택하지 않음’이 클릭된 상황에서 다른 메뉴를 클릭하면, ‘선택하지 않음’이 삭제되고, 클릭한 메뉴가 주문에 추가된다.

원하는 결과

  • setOrders(orders.filter(order => order !== "선택하지 않음"));로 배열에서 ‘선택하지 않음’ 이 삭제되고, setOrders(orders => [...orders, selectedItem]);로 클릭한 메뉴가 주문에 추가된다.

실제 결과

  • 배열에서 ‘선택하지 않음’이 삭제되지 않고, 클릭한 메뉴가 추가된다.
    문제상황2

setOrders(), 즉 useState() hooks로 만든 setState()를 연속적으로 사용하면 마지막(해당 예시에서는 두 번째) setState()만 실행되는 것처럼 보인다. 왜그럴까?

세 가지만 기억하자.

1) setState()는 비동기로 처리된다.

2) setState()를 연속적으로 호출하면 Batch 처리를 한다.

3) state는 객체이다.

setState() 함수가 호출되면 리액트는 바로 전달받은 state로 값을 바꾸는 것이 아니라 이전의 리액트 엘리먼트 트리와 전달받은 state가 적용된 엘리먼트 트리를 비교하는 작업을 거치고, 최종적으로 변경된 부분만 DOM에 적용한다. 이 과정은 나도 정확히 알지는 못하지만, 어쨌든 비동기로 처리되고 꽤 번거롭다는 것이다! 따라서 리액트는 setState가 연속적으로 호출되면, “아, 이 번거로운 작업을 한 번에 할 수 없을까?” 라고 생각하고 전달 받은 각각의 state를 합치는(merging) 작업을 수행 한 뒤에 한 번에 setState()를 한다.

merging은 어떻게 동작할까?

세 번째 케이스로 동작을 살펴본다면… 현재 orders에 [“감자 튀김 🍟”, “콜라 🥤”] 가 있다고 했을 때, 배열을 초기화 하고(setOrder([])), “선택하지 않음”을 추가하는 부분(setOrders([...orders, selectedItem]);)을 코드로 살펴보면 다음과 같다.

const newState = Object.assign(
  { orders : ["감자 튀김 🍟", "콜라 🥤"] },
  { orders : [] },
  { orders : [ ...orders, "선택하지 않음"]}
)

setOrders(newState)

Object.assign()으로 여러 개의 객체를 합칠 때, 같은 key를 가지고 있다면 이 전의 값이 덮어씌워지기 때문에 결국 { orders : [ ...orders, "선택하지 않음"]} 마지막 명령어가 실행된다. 마지막 명령어가 실행될 때의 orders는 아직 변경되기 전인 상태, 즉 [“감자 튀김 🍟”, “콜라 🥤”] 이기 때문에 배열이 초기화 되지 않고 “선택하지 않음”이 추가되는 것이다. 네 번째 케이스로도 직접 따져보면 좋을 것 같다.

그럼 어떻게 해결하나요?

index

types/react 모듈의 index.d.ts


react 코드를 까보면 setState() 함수는 인자로 (1) 새로운 state 객체 를 받을 수도 있고, (2) 이전 state 객체를 인자로 받고 새로운 state 객체를 반환하는 함수 를 받을 수도 있다. 결론부터 이야기하면 (2)의 방법으로 했을 때 여러번의 setState() 를 문제없이 실행할 수 있다.

setState()가 비동기적으로 동작한다는 것은 변함이 없지만, 인자로 넘겨 받는 함수들은 Queue에 저장되어 순서대로 실행된다. 따라서 첫 번째 함수가 실행된 후 리턴하는 업데이트된 state가 두 번째 함수의 인자로 들어가는 방식으로 ‘state의 최신 상태가 유지된다.’

  const onClickHandler = selectedItem => {
    if (selectedItem === "선택하지 않음") {
      setOrders(orders => []);
    }

    if (orders.includes(selectedItem)) {
      setOrders(orders => orders.filter(order => order !== selectedItem));
      return;
    }

    if (orders.includes("선택하지 않음")) {
      setOrders(orders => orders.filter(order => order !== "선택하지 않음"));
    }
    setOrders(orders => [...orders, selectedItem]);
  };

앞서 공유한 코드 중 onClickHandler의 내부 로직을 함수형 setState() 로 변경하여 정상적으로 동작하도록 수정해보자.


type SetStateAction<S> = S | ((prevState: S) => S);
type Reducer<S, A> = (prevState: S, action: A) => S;

types/react 모듈의 index.d.ts을 살펴보면 함수형 setState와 Reducer의 내부 로직이 같은 것을 확인할 수 있다. setState()로 넘기는 함수의 로직을, Reducer의 action type과 매칭되는 함수에 넣어서 실행 시킨 뒤 새로운 state 객체를 반환한다.

이 부분을 공부하면서 언제 useState를 쓰고 언제 useReducer를 써야 하냐? 의 질문에도 힌트를 얻을 수 있었는데, 결론은 상태를 관리하는 로직이 어느 정도 수준 이상으로 복잡해지면 useReducer를 쓰자 이다. UI 와 상태관리 로직을 분리하는게 좋다라는 말이 이런 의미인지 생각해보게 된다.


2. setState()를 실행한 뒤에 곧 바로 api 호출을 보냈을 때

나의 경우에는 사용자가 ‘결제하기’ 나 ‘제출하기’ 등의 버튼을 눌렀을 때, POST 요청에 함께 보내야 하는 state값을 변경하고 POST 요청을 해야 하는 케이스가 있었다. 업데이트된 상태값을 넘겨야 하니까 머리로 “setState()를 실행하고 POST 요청을 보내야지!” 생각하고 코드를 짰었는데, setState()도 비동기 / POST 요청도 비동기로 처리되며 심지어 POST 요청의 우선 순위가 더 높아 업데이트된 상태값이 전달되지 않는 문제가 있었다.

사실 POST 요청을 보내고 다른 페이지로 이동하는 동작이여서 업데이트된 상태를 가지고 있을 필요가 없었고, 따라서 setState()를 굳이 실행하지 않고 일반 객체로 만들어 전달하여 해결했다. setState() 를 사용할 때 정말 여기서 실행해야 하나? 라고 한 번 정도 더 생각하는 습관을 가지면 좋겠다.


참고한 글


Table of contents