[React] 선택/취소 기능: 2️⃣ 비동기로 동작하는 setState

sjoleee·2022년 9월 18일
0
post-thumbnail

지난 글 보러가기 >> [React] 선택/취소 기능: 1️⃣ 멋진 코드 짜려다 UX를 고려하지 못한 이야기

해결 실패

그러나, 원하는 대로 동작하지 않았다.
바로 set함수가 비동기로 동작하기 때문이었다

위쪽에서 카드를 선택하면, 아래쪽 리스트에 isSelectedtrue인 상태로 추가되어야 하는데, false인 상태로 추가되고 있었다.
그래서 handleDeselect를 해봤자... 이미 false인데 뭘 선택취소야? 라는 상황이 발생하는 것.

//handleSelect 함수
  const handleSelect = (id: number) => {
    isSelectedChange(); //클릭된 카드의 isSelected값을 변경해주는 함수
    members.forEach((item) =>
      item.id === id ? setSelectedMembers([...selectedMembers, item]) : null,
    ); //selectedMembers에 클릭된 카드를 추가해준다.
  };

handleSelect 함수에서 isSelectedChange 함수를 사용하여 isSelected값을 변경해주고 있다.

//isSelectedChange 함수
  const isSelectedChange = () => {
    const newMembers = [...members].map((item) =>
      item.id === id ? { ...item, isSelected: !item.isSelected } : item,
    ); //클릭된 카드의 isSelected를 변경한 newMembers를 생성
    setMembers(newMembers); //😡이녀석이 비동기로 동작한다!!!
  };

내가 생각한 대로라면,

  • 클릭한 카드의 isSelected가 변경된다.
  • 변경을 set함수로 members에 반영한다.
  • 변경이 반영된 members에서 클릭된 카드를 찾아내서 selectedMembers에 추가한다

이런 흐름으로 가야하는데, 실제로 동작하는 것은

  • 클릭한 카드의 isSelected가 변경된다.
  • 변경을 set함수로 members에 반영한다. 비동기로 동작하기 때문에 뒤에서 실행됨
  • 변경이 반영된 members에서 클릭된 카드를 찾아내서 selectedMembers에 추가한다
  • 미뤄놨던 set함수로 members가 업데이트된다.

엄밀히 말하자면 selectedMembers를 업데이트하기 전인데, 알아보기 쉽게 마지막으로 넣었다.
(selectedMembers에서 추가하고자 하는 item은 그 시점에서 아직 isSelected가 업데이트 되지 않은 상태이다.)

자세히 보기

함수를 합치고, 한 줄씩 차근차근 보도록 하자.


//handleSelect 함수
  const handleSelect = (id: number) => {
        
    const newMembers = [...members].map((item) =>
      item.id === id ? { ...item, isSelected: !item.isSelected } : item,
    );
    //newMembers는, members배열에서 내가 클릭한 item만 isSelected값을 토글한 배열이다.
    //members가 [{id: 1, name: 이상조, isSelected: false}, {id: 2, name: 김코딩, isSelected: false}]라고 가정해보자. 
    //만약 내가 '이상조' 카드를 클릭했다면?
    //newMembers는 [{id: 1, name: 이상조, isSelected: true}, {id: 2, name: 김코딩, isSelected: false}]가 될 것이다.
    
    setMembers(newMembers);
    //setMembers로 members를 업데이트한다.
    //그러나, set이 비동기로 동작하기 때문에 실제로 members의 값은 변하지 않은 상태임.
  };

    members.forEach((item) =>
      item.id === id ? setSelectedMembers([...selectedMembers, item]) : null,
    ); //변하지 않은 members를 갖고... selectedMembers에 클릭된 카드를 추가해준다 = 해당 카드는 클릭됐지만 isSelected가 변하지 않은 상태로 추가됨
  };

Recoil에서도 동일할까?

근데, 해당 실험은 zustand 환경에서 진행한 것이라 혹시나 하는 마음에 Recoil에서도 동일하게 실험해보았다.

//atom 생성
const testAtom = atom({
  key: "test",
  default: [
    { name: "이상조", isSelected: false },
    { name: "김코딩", isSelected: false },
  ], //초기값을 둘 다 false로 설정함.
});

const [members, setMembers] = useRecoilState(testAtom);


 const testFn = (name: string) => {
    const newMembers = [...members].map((item) =>
      item.name === name ? { ...item, isSelected: !item.isSelected } : item
    );
    console.log("newMembers는?", newMembers); //이상조 > true, 김코딩 > false로 원하는대로 변경되었음.
    console.log("set하기 전 members는?", members); //이상조 > false, 김코딩 > false로 초기값.
   
    setMembers(newMembers); //set함수로 newMembers로 업데이트 시켜주었다!
   
   console.log("set하고 나서 members는?", members); //이상조 > false, 김코딩 > false로 변경되지 않은 값이 출력됨.
  };

  useEffect(() => {
    testFn("이상조");
  }, []);

setState는 비동기로 동작한다.

setState와 같이 작동하는 set함수는 라이브러리와 관계없이 동일한 것인가?
React의 setState도, zustand도, Recoil도 동일하게 비동기로 작동하는데, 구체적인 이유가 궁금하다.
대체 setState는 어떻게 작동하길래 바로 업데이트 안되고 비동기로 동작하는건가?
그리고 왜?

몇시간이나 웹을 뒤져봤는데, 많은 분들이 정리해둔 글은 엄청나게 많았다.
요약하자면 아래와 같다.

setState는 렌더링을 최적화하기 위해 16ms 단위로 batch update를 진행하고, 변경된 상태값을 모아서(merge) 이전의 엘리먼트 트리와 변경된 state가 적용된 엘리먼트 트리를 비교하는 작업(reconciliation)을 거쳐 최종적으로 변경된 부분만 DOM에 적용시킨다.

이런 이유라는데, 솔직히 나는 setState가 어떻게 작동하는건지 코드를 다 뜯어보고 싶다. 근데 공식문서에도 없다 ㅜㅜ...
16ms 이야기도 출처가 애매한 느낌이다. 개인블로그에서 다 같은 내용을 적어두어서... 누가 처음 저 이야기를 한건지도 궁금하다.

사실 한달쯤 전에 프로젝트를 진행하면서 관련된 문제를 겪었는데, 당시에는 setState때문이라고 생각 자체를 못해서 어떻게든 다른 방법을 찾아서 해결하고 넘어갔던 것 같다...

당장은 찾을 수 없으니, 알게 되면 내용을 추가하도록 하겠다!

setState는 최신값을 보장하지 않는다.

그리고, setState가 비동기로 동작하면서 생기는 또다른 sideEffect를 찾았다.
변경된 상태값을 모아서 처리하는 부분에서 발생하는 문제다.


const Test = () => {
  const testAtom = atom({
    key: "test",
    default: [], //초기값은 빈배열
  });

  const [members, setMembers] = useRecoilState(testAtom);

  useEffect(() => {
    setMembers(["이상조"]);
    setMembers([...members, "김코딩"]);
  }, []);

  return <div>{members}</div>;
};

이런 컴포넌트를 만들었다고 생각해보자.
과연 화면에 출력되는 값은 무엇일까?
이상조, 김코딩이 출력되어야 하지 않을까?

그러나, 실제로는 이렇게 출력된다.

이유는, setState가 연속적으로 호출되면 여러 state를 합치는(merging) 작업을 수행 한 뒤에 한 번에 setState()를 수행하기 때문이다.
Object.assign를 통해 이 merging 작업이 어떻게 수행되는지 살펴보자.

//초기값은 []

const newState = Object.assign(
  { members : ["이상조"] },
  { members : [ ...members, "김코딩"]}
)

setMembers(newState)

Object.assign은 같은 key를 가지고 있다면 값이 덮어씌워지기 때문에 이전 명령들은 다 무시되며, 결국 마지막 명령어{ members : [ ...members, "김코딩"]} 만 실행된다.
따라서 마지막 명령어가 실행될 때의 members는 초기값인 []이기 때문에 "김코딩"만 추가되는 것이다.

setState에 대해서는 계속해서 공부해야 할 듯 하다!!!

profile
상조의 개발일지

0개의 댓글