React 상태 업데이트 불변성의 이유

plrs·2023년 11월 10일
1
post-thumbnail

원시 타입과 참조 타입

자바스크립트의 자료형은 원시 타입과 참조 타입으로 나뉜다. 원시 타입의 값은 stack에 저장되고 참조 타입의 값은 heap에 저장된다. 원시 타입은 비교 연산시 값으로 비교하지만, 참조 타입은 변수가 heap의 같은 데이터를 가리키는지 여부로 비교한다.

사실 이건 이 글이 아니여도 잘 설명해주는 곳이 많으니 더 자세히 알고 싶다면 다른 글을 참고하는 것을 추천한다.

React는 어떻게 디펜던시를 비교할까

View는 상태의 조합의 스냅샷이다. React는 상태의 변경이 발생했을 시 새로운 스냅샷을 만드는 과정에서(리렌더링) 디펜던시 비교를 통해 재실행 여부를 결정한다.

예를 들어,

  • React.memo를 통해 컴포넌트 렌더링을 캐싱한다. 이때 props를 디펜던시로 사용해 재사용 여부를 결정한다.
  • setState 에 같은 값(참조 타입이라면 레퍼런스)을 전달할 경우 bail-out 한다.
  • useEffect, useMemo, useCallback 등은 배열로 전달하는 디펜던시의 비교를 통해 callback 실행 여부를 결정한다.

이런 각종 디펜던시 비교 과정에서 React는 내부적으로 Object.is 를 사용한다.

Object.is 는 사실상 === 동등 연산자와 거의 동일하다. (자세한 내용은 MDN 참고)

그래서?

mutable의 첫 번째 문제는 리렌더링 과정 자체가 bail-out 될 수 있다는 것이다. 앞서 말했듯 setState는 같은 값을 전달한다면 다음 스냅샷 렌더링 과정 도중 bail-out 한다. 따라서 참조 타입의 값을 mutable 하게 변경했다면 리렌더링 과정이 의도대로 진행되지 않는다.

두 번째 문제는 리렌더링이 되더라도 해당 서브트리 재구축 과정에서 생기는 디펜던시 비교 과정이 의도한 것과 다르게 동작할 수 있다는 것이다.

import React from 'react';

export default function App(props) {
  const [state, setState] = React.useState({ foo: { bar: 1 } });

  console.log('rerender');

  const handleClick = () => {
    setState((_state) => {
      return {
        foo: _state.foo,
      };
    });
  };

  return (
    <div>
      <button onClick={handleClick}>rerender</button>
      <Comp dependency={state} />
    </div>
  );
}

function Comp({ dependency }) {
  React.useEffect(() => {
    console.log('triggered');
  }, [dependency.foo]);

  return null;
}

다음과 같은 예시 상황을 보자.

리렌더링 시 Comp 하위 컴포넌트의 useEffect가 trigger 되는 상황을 원했다면 state.foo의 identity는 리렌더링 간 일정하게 유지되기 때문에 의도된 대로 동작하지 않을 것이다.

setState((_state) => {
  return {
    foo: {
      bar: 1,
    },
  };
});

반면, 이렇게 변경하면 의도한 대로 잘 동작한다.

예제는 useEffect의 디펜던시를 예시로 사용했지만, 앞서 말했듯 React는 각종 디펜던시를 Object.is 로 비교하기 때문에, 서브트리 리렌더링시 의도한 대로 일관된 동작을 가져온다는 보장을 위해 상태 변경 시 불변성을 지켜줘야 한다.

profile
👋

0개의 댓글