FE/React

왜 memo에서 객체 리터럴은 리렌더링되고, useState 객체는 리렌더링되지 않을까?

고감귤 2025. 6. 28. 17:43

 

React.memo는 전달된 props가 이전과 얕은 비교(shallow equal) 시 같다면, 컴포넌트를 리렌더링하지 않는다.

JavaScript의 얕은 비교는 === 연산자로 판단되며, 객체는 참조값이 같아야 === 연산자의 결과가 true가 된다.


객체 리터럴 사용 시 문제

'use client'
import { memo, useState } from "react";

const Test = () => {
    const object = { count: 0 }; // 매 렌더마다 새 객체 생성
    const [count, setCount] = useState(0);

    return (
        <div>
            <button onClick={() => setCount(c => c + 1)}>Click</button>
            <div>{count}</div>
            <Child object={object} />
        </div>
    );
};

const Child = memo(({ object }: { object: { count: number } }) => {
    console.log('Child render');
    return <div>Child {object.count}</div>;
});

이 경우, 버튼을 누를 때마다 Child render가 콘솔에 출력된다.

이유: object = { count: 0 }매번 새로운 객체이므로 참조값이 다르다.

따라서 memo가 이전 props와 다르다고 판단한다.

 

useState로 관리된 원시값 전달 시

function Page() {
  const [name, setName] = useState('Taylor');
  const [age, setAge] = useState(42);
  const [toggle, setToggle] = useState(true);

  return <Profile name={name} age={age} />;
}

const Profile = memo(function Profile({ name, age }) {
  console.log('Profile render');
  return <div>{name}, {age}</div>;
});

이 경우 setToggle()을 호출해도 Profile은 리렌더링되지 않는다.

이유: name과 age는 원시값으로 값이 동일하면 ===도 true다.

 

useState로 객체를 다룰 때는?

'use client'
import { memo, useState } from "react";

const Test = () => {
    const [object, setObject] = useState<{ count: number }>({ count: 0 });
    const [count, setCount] = useState(0);

    return (
        <div>
            <button onClick={() => setCount(c => c + 1)}>Click</button>
            <div>{count}</div>
            <Child object={object} />
        </div>
    );
};

const Child = memo(({ object }: { object: { count: number } }) => {
    console.log('Child render');
    return <div>Child {object.count}</div>;
});

이 경우, 버튼을 눌러도 Child render는 출력되지 않는다.

이유: useState로 생성한 objectsetObject를 호출하지 않는 이상, 같은 참조값을 유지하기 때문이다.

 

useState로 객체를 다룰 때 참조 값이 유지되는 것은, hook 연결 리스트 구조에 있다

React는 각 컴포넌트가 호출하는 Hook들을 기억하기 위해 단방향 연결 리스트 구조를 사용한다.

React는 Hook을 이름으로 구분하지 않고, 호출되는 순서대로 상태를 저장하고 재사용한다.

예를 들어 첫 번째로 호출된 useState는 첫 번째 Hook 객체로, 두 번째 useEffect는 두 번째 Hook 객체로 저장된다.

이 순서가 바뀌면, React는 이전 렌더와 다음 렌더 사이에 Hook을 잘못 연결하여 엉뚱한 상태를 읽거나 적용하게 되고, 이는 치명적인 버그로 이어진다.

따라서 Hook 호출 순서를 정확히 재현하고 상태를 일관되게 유지하려면, 이러한 연결 리스트 구조가 반드시 필요하다.

 

현재 렌더링 중인 컴포넌트의 fiber.memoizedState에 최상단의 Hook이 저장되며, 이후 Hook들은 순차적으로 이어진다.

그리고 중요한 점은, 업데이트가 발생하더라도 이 Hook 연결 구조는 유지된다는 것이다.

즉, React는 current fiber의 Hook 목록을 기반으로 업데이트가 발생해 새롭게 생성되는 피버트리인 workInProgress fiber의 Hook들을 순서대로 복사 또는 재사용하고, 그 과정에서 각 Hook의 memoizedState 역시 유지된다.

이는 동일한 순서로 호출되는 Hook이 동일한 상태를 참조할 수 있게 보장해준다.

 

function updateWorkInProgressHook(): Hook {
  // This function is used both for updates and for re-renders triggered by a
  // render phase update. It assumes there is either a current hook we can
  // clone, or a work-in-progress hook from a previous render pass that we can
  // use as a base.
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.

    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      if (currentFiber === null) {
        // This is the initial render. This branch is reached when the component
        // suspends, resumes, then renders an additional hook.
        // Should never be reached because we should switch to the mount dispatcher first.
        throw new Error(
          'Update hook called on initial render. This is likely a bug in React. Please file an issue.',
        );
      } else {
        // This is an update. We should always have a current hook.
        throw new Error('Rendered more hooks than during the previous render.');
      }
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}