본문 바로가기
웹/프론트엔드

Zustand 현명하게 사용하기 (불필요한 리렌더링 막기)

by 이민훈 2023. 6. 12.

여기 아주 간단한 zustand 스토어와, 2개의 컴포넌트가 있습니다.

 

import { create } from "zustand";

interface AppStoreStates {
  a: number;
  b: number;

  setA: (a: number) => void;
  setB: (b: number) => void;
}

export const appStore = create<AppStoreStates>((set) => ({
  a: 0,
  b: 0,

  setA: (a: number) => set({ a }),
  setB: (b: number) => set({ b }),
}));

 

import { appStore } from "./store/app";

const A = () => {
  const { a, setA } = appStore();

  console.log("a rendering")

  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};

const B = () => {
  const { b, setB } = appStore();

  console.log("b rendering")

  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set B</button>
    </>
  );
};

function App() {
  return (
    <>
      <A />
      <B />
    </>
  );
}

export default App;

 

A 컴포넌트에 있는 버튼을 눌렀을 때, 대부분의 React 개발자는 A 컴포넌트만 리렌더링 되기를 바랄 겁니다.

 

하지만 해당 방식으로 사용할 경우 appStore 내부의 모든 상태를 반환하기 때문에, 버튼을 누를 시, 두 컴포넌트 모두 리렌더링 됩니다.

 

두 컴포넌트 전부 리렌더링되는 모습

 

A, B 컴포넌트를 아래와 같이 바꾸면 어떨까요?

 

const A = () => {
  const a = appStore((state) => state.a);
  const setA = appStore((state) => state.setA);

  console.log("a rendering");

  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};

const B = () => {
  const b = appStore((state) => state.b);
  const setB = appStore((state) => state.setB);

  console.log("b rendering");

  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set B</button>
    </>
  );
};

 

한 컴포넌트만 리렌더링되는 모습

 

Object가 아닌, primitive type의 변수와 절대 바뀌지 않는 action 함수만을 들고 오기에, 리렌더링이 일어나지 않습니다.

 

A 컴포넌트에서 a를 바꿔도, B 컴포넌트의 b는 바뀌지 않기 때문이죠.

 

b는 primitive type인 number이기 때문에 value가 바뀌지 않는 한, 리렌더링이 일어나지 않습니다.

 

위와 같은 방식을 사용할 거라면, 이런 식으로 hook을 만들어 사용할 수도 있겠네요.

 

export const useA = () => {
  const a = appStore((state) => state.a);
  const setA = appStore((state) => state.setA);

  return { a, setA };
};

 

하지만 B 컴포넌트에서 b를 const { b } = appStore() 와 같이 들고 온다면, B 컴포넌트에서는 리렌더링이 발생하게 됩니다.

 

사실 들고 온 값은 { a, b, setA, setB } 라는 오브젝트이기 때문입니다.

 

오브젝트라서 왜 리렌더링이 되는지는 아래에서 좀 더 설명하겠습니다.

 

const A = () => {
  const { a, setA } = appStore((state) => ({ a: state.a, setA: state.setA }));

  console.log("a rendering");

  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};

const B = () => {
  const { b, setB } = appStore((state) => ({ b: state.b, setB: state.setB }));

  console.log("b rendering");

  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set B</button>
    </>
  );
};

 

해당 코드는 A 컴포넌트에 있는 버튼을 누를 때, B 컴포넌트에서 리렌더링이 발생할까요?

 

불필요한 리렌더링이 발생하는 모습

 

해당 방식으로 state를 들고 오는 경우에도 불필요한 리렌더링이 발생합니다.

 

기본적으로 zustand는 react처럼, state의 변경에 === 연산을 사용합니다. 오브젝트의 경우 메모리 주소를 비교한다는 뜻이죠.

 

useState로 array나 object를 써보신 분들이라면 array나 object의 원소값만 바꾼다고 리렌더링이 되지 않는다는 것을 아실 겁니다.

 

setArray((prev) => [...prev, value]) 와 같은형태로 새로운 배열을 할당해 주어야 리렌더링이 되죠.

 

같은 맥락으로, 리렌더링을 위해선 zustand의 set 함수를 통해 오브젝트를 새로운 오브젝트로 바꿔줘야 합니다.

 

만약 set 함수를 아래와 같이 구현하면, 리렌더링이 되지 않겠죠?

 

export const appStore = create<AppStoreStates>((set) => ({
  a: 0,
  b: 0,

  setA: (a: number) =>
    set((prev) => {
      prev.a = a;
      return prev;
    }),
  setB: (b: number) =>
    set((prev) => {
      prev.b = b;
      return prev;
    }),
}));

 

리렌더링을 위해선 새로운 오브젝트를 할당해야 함

 

위와 다르게, setA: (a: number) => set({ a }) 구문은 새로운 오브젝트를 할당하는 구문입니다.

 

그래서 setA가 실행된 시점에 store의 state인 { a, setA, b, setB } 는 새로운 오브젝트로 바뀌게 되고,

 

B 컴포넌트에서 a를 가져오지 않았지만, 리렌더링이 발생하게 됩니다.

 

const A = () => {
  const { a, setA } = appStore(
    (state) => ({ a: state.a, setA: state.setA }),
    (a, b) => {
      console.log("a equal?", a === b);
      return a === b;
    }
  );

  console.log("a rendering");

  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};

const B = () => {
  const { b, setB } = appStore(
    (state) => ({ b: state.b, setB: state.setB }),
    (a, b) => {
      console.log("b equal?", a === b);
      return a === b;
    }
  );

  console.log("b rendering");

  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set B</button>
    </>
  );
};

 

오브젝트의 동등성 검사

 

custom 함수로 a와 b의 동등성을 검사해 보면 false가 나오게 됩니다.

 

{ b, setB }의 메모리 주소가 바뀌었기 때문에 B 컴포넌트 또한 리렌더링 되는 것이죠.

 

import { shallow } from "zustand/shallow";
import { appStore } from "./store/app";

const A = () => {
  const { a, setA } = appStore(
    (state) => ({ a: state.a, setA: state.setA }),
    shallow
  );

  console.log("a rendering");

  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};

const B = () => {
  const { b, setB } = appStore(
    (state) => ({ b: state.b, setB: state.setB }),
    shallow
  );

  console.log("b rendering");

  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set B</button>
    </>
  );
};

 

store에서 값을 가져올 때, 2번째 인자로 비교함수를 넣어줄 수 있는데,

 

이때 zustand에서 제공되는 shallow 함수를 인자로 사용하게 되면 오브젝트 프로퍼티의 값 또한 비교하게 됩니다.

 

https://github.com/pmndrs/zustand/blob/main/src/shallow.ts

 

GitHub - pmndrs/zustand: 🐻 Bear necessities for state management in React

🐻 Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.

github.com

 

object property의 값까지 비교하는 shallow 함수

 

B 컴포넌트의 { b, setB }는 메모리 주소가 변경되었지만, 값이 변경되지 않았으므로 리렌더링 대상이 아니게 되는 것이죠.

 

동등성 검사를 위해 직접 custom 함수를 만들어 넘겼던 것처럼,

 

원하는 함수를 인자로 넘겨줄 수도 있으니, 필요하다면 시도해 보는 것도 좋을 것 같습니다.

 

정리 해보자면,

 

1. state 중에 === 연산자를 사용했을 때 값을 비교하는 primitive type이나, 변하지 않는 action만을 들고 오기

 

2. state 중 필요한 값들을 object 형태로 들고 오되, shallow 함수로 동등성 비교를 하기

 

3. 동등성을 비교하는 custom 함수를 만들어 인자로 넘겨주기

 

취향에 따라 셋중 하나를 사용하면 불필요한 리렌더링을 막을 수 있을 것입니다.

 

저는 대부분의 프로덕트 개발에서 값이 변경되었을 때, 리렌더링이 필요한 경우가 많았습니다.

 

그래서 store 자체를 훅으로 래핑하여 사용하는데요.

 

store와 필요한 key를 받아 state를 반환하는 useShallow 훅을 먼저 만들어 줍니다. (shallow 함수를 인자로 넘겨줍니다)

 

import { StoreApi, UseBoundStore } from "zustand";
import { shallow } from "zustand/shallow";

export const useShallow = <T, K extends keyof T>(
  store: UseBoundStore<StoreApi<T>>,
  keys: K[]
): Pick<T, K> => {
  return store((state) => {
    const result = {} as { [K in keyof T]: T[K] };
    keys.forEach((key) => {
      result[key] = state[key];
    });
    return result;
  }, shallow);
};

 

그런 다음, useAppStore라는 훅을 만들어 줍니다.

 

import { create } from "zustand";
import { useShallow } from "../hooks/useShallow";

interface AppStoreStates {
  a: number;
  b: number;

  setA: (a: number) => void;
  setB: (b: number) => void;
}

export const appStore = create<AppStoreStates>((set) => ({
  a: 0,
  b: 0,

  setA: (a: number) => set({ a }),
  setB: (b: number) => set({ b }),
}));

export const useAppStore = <T extends keyof AppStoreStates>(keys: T[]) => {
  return useShallow(appStore, keys);
};

 

그럼, 아래와 같이 간략한 코드로 shallow 함수를 동등성 함수로 사용하는 state를 얻을 수 있습니다.

 

const A = () => {
  const { a, setA } = useAppStore(["a", "setA"]);

  console.log("a rendering");

  return (
    <>
      <div>{a}</div>
      <button onClick={() => setA(a + 1)}>set A</button>
    </>
  );
};

const B = () => {
  const { b, setB } = useAppStore(["b", "setB"]);

  console.log("b rendering");

  return (
    <>
      <div>{b}</div>
      <button onClick={() => setB(b + 1)}>set A</button>
    </>
  );
};

댓글