Skip to content

Latest commit

 

History

History
202 lines (164 loc) · 8.02 KB

3주차_최여진_개인.md

File metadata and controls

202 lines (164 loc) · 8.02 KB

Zustand useShallow 제대로 쓰기

들어가며

Zustand로 상태관리를 하다 보면 의도치 않은 리렌더링이 발생할 수 있습니다. 이런 문제를 해결하기 위해 Zustand 가 제공하는 useShallow 를 사용하곤 합니다.

useShallow 가 왜 필요하고 어떤 상황에서 사용해야 하는지에 대해 알아보았습니다.

먼저 간단한 유저 스토어를 만들어보겠습니다.

const useUserStore = create(set => ({
 name: 'John',
 company: 'Google',
 age: 20,
 hobbies: ["운동", "게임", "코딩"]
 increaseAge: () => set((state) => ({ age: state.age + 1 }))
}))

유저 스토어를 구독하고 있는 컴포넌트가 여러 state 에 대해 구독하고 있다면, 다음과 같이 쉽고 편하게 구조 분해 할당으로 가져올 수 있을 것 같습니다.

// Bad: 구조 분해 할당
const Component = () => {
  const { name, company } = useUserStore()
  console.log("rerender!")
  return <div>{name}: {company}</div>
}

// age만 변경
useUserStore.increaseAge()

// name을 사용하지 않는데도 리렌더링 발생! ("rerender!" 출력)

그런데 이렇게 구조 분해 할당을 사용하고 selector 없이 호출하여 사용하는 경우  store 전체를 구독하게 됩니다. 컴포넌트가 구독하고 있는 값인 name , company 이 아니라 age 값을 변경하였는데도 의도하지 않게 리렌더링이 발생합니다.

따라서 이러한 경우 selector 를 사용해 필요한 값만 선택하는 것이 좋습니다.

// Good: selector로 필요한 값만 선택
const Component = () => {
  const name = useUserStore(state => state.name)
  const company = useUserStore(state => state.company)
  console.log("rerender!")
  return <div>{name}</div>
}

// age만 변경
useUserStore.increaseAge()

// name을 구독하고 있지만 age만 변경되었으므로 리렌더링 발생하지 않음

위 코드에서 selector를 사용하여 각각의 값을 개별적으로 구독하므로 name이나 company가 아닌 다른 값이 변경될 때는 리렌더링이 발생하지 않습니다.

하지만 컴포넌트에 구독하는 state 가 매우 많다면 코드가 장황해지고, 상태 변화를 추적하기 어려워질 수 있습니다. 이런 경우 useShallow를 사용하면 여러 state 를 한 번에 구독하면서도 불필요한 리렌더링을 방지할 수 있습니다.

여러 state 에 접근하기

공식 문서에서는 useShallow를 사용하여 여러 state를 얻거나, 단일 상태를 구독하는 커스텀 훅을 재사용 할것을 권장하고 있습니다.

// 1️⃣ 컴포넌트에서 useShallow로 여러 상태 구독
const Component = () => {
  const { name, company } = useUserStore(
    useShallow((state) => ({
      name: state.name,
      company: state.company
    }))
  );
  return <div>{name}: {company}</div>;
};

// 2️⃣ 단일 상태를 추출하는 커스텀 훅 생성
export const useName = () => useUserStore((state) => state.name);
export const useCompany = () => useUserStore((state) => state.company);

const Component = () => {
  // 컴포넌트에서 selector 작성 로직이 제거됨
  const name = useName();
  const company = useCompany();
  return <div>{name}: {company}</div>;
};

selector 가 반환하는 값의 종류에 따른 리렌더링

하지만 여기서 주의할 점이 있습니다. selector가 반환하는 값의 종류에 따라 useShallow의 필요성이 달라집니다.

// 1️⃣ 원시값을 반환하는 경우 - useShallow 필요없음
export const useName = () => useUserStore((state) => state.name);
export const useAge = () => useUserStore((state) => state.age);

// 2️⃣ 새로운 객체를 반환하는 경우 - useShallow 필요!
export const useUserInfo = () => useUserStore((state) => ({
  name: state.name,
  company: state.company
}));
// ❌ 매번 새로운 객체가 생성되어 불필요한 리렌더링 발생

// ✅ useShallow 사용으로 최적화
export const useUserInfo = () => useUserStore(
  useShallow((state) => ({
    name: state.name,
    company: state.company
  }))
);

즉, selector가 새로운 객체나 배열을 생성하여 반환할 때는 반드시 useShallow를 사용해야 불필요한 리렌더링을 방지할 수 있습니다.

useShallow의 내부 동작 원리

useShallow는 내부적으로 shallow 비교 함수를 사용하여 이전 상태와 현재 상태의 차이를 감지합니다. 이 비교 과정은 크게 세 단계로 이루어집니다.

1. 기본 비교

먼저 Object.is를 사용하여 기본적인 비교를 수행합니다.

if (Object.is(valueA, valueB)) {
  return true
}

if (
  typeof valueA !== 'object' ||
  valueA === null ||
  typeof valueB !== 'object' ||
  valueB === null
) {
  return false
}

이 단계에서는 두 값이 정확히 같은 참조를 가지는지 확인하고, 둘 중 하나라도 객체가 아니거나 null인 경우 false 를 반환합니다.

2. 객체 비교

객체인 경우 최상위 속성들을 비교합니다.

const compareEntries = (
  valueA: { entries(): Iterable<[unknown, unknown]> },
  valueB: { entries(): Iterable<[unknown, unknown]> },
) => {
  // Map 인스턴스이면 그대로 사용, 일반 객체의 경우 `Object.entries()`로 얻은 key-value 쌍을 Map으로 변환
  const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries())
  const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries())

  // 객체의 크기(프로퍼티 개수)가 다르면 false
  if (mapA.size !== mapB.size) {
    return false
  }

  // 각 프로퍼티의 값을 Object.is로 비교
  for (const [key, value] of mapA) {
    if (!Object.is(value, mapB.get(key))) {
      return false
    }
  }
  return true
}

객체들을 비교할 때는 Map 자료구조를 활용하여 객체의 프로퍼티들을 비교합니다. 객체의 모든 프로퍼티를 Map으로 변환한 뒤, 크기가 다르거나 같은 키의 값이 다르면 false를 반환합니다. 이렇게 하여 객체의 최상위 프로퍼티들의 변경을 감지할 수 있습니다.

배열과 이터러블 객체 비교

const compareIterables = (
  valueA: Iterable<unknown>,
  valueB: Iterable<unknown>,
) => {
  // 각각의 Iterator 가져오기
  const iteratorA = valueA[Symbol.iterator]()
  const iteratorB = valueB[Symbol.iterator]()

  // next 메서드로 순차적으로 값에 접근
  let nextA = iteratorA.next()
  let nextB = iteratorB.next()

  // 두 Iterator를 동시에 순회하면서 Object.is로 값 비교
  while (!nextA.done && !nextB.done) {
    if (!Object.is(nextA.value, nextB.value)) {
      return false
    }
    nextA = iteratorA.next()
    nextB = iteratorB.next()
  }

  // 둘 다 순회가 끝났는지 확인
  return !!nextA.done && !!nextB.done
}

배열과 같은 순회 가능한(iterable) 객체들을 비교할때는 Iterator 를 활용하여 배열의 각 요소를 비교 값이 다르거나, 순서가 다르거나 길이가 다르면 false를 반환합니다. 이렇게 하여 배열이나 Set 같은 iterable 객체의 변경을 감지할 수 있습니다.

결론

  • Zustand에서 상태를 구독할 때는 useShallow를 사용하여 여러 상태를 구독하고, 불필요한 리렌더링을 방지할 수 있습니다.
  • 하지만 useShallow 는 엄격한 동등성 검사를 하는 Object.is 와 달리 객체의 최상위 속성만 비교하는 얕은 비교만 수행하므로, 중첩된 객체의 깊은 변화는 감지하지 못할 수 있습니다.

참고