본문으로 건너뛰기
김도현

Zustand의 불필요한 리렌더링 막기

의도한대로 렌더링 해봅시다

Zustand와 리렌더링

아래와 같이 Zustand 스토어와 두 개의 컴포넌트가 있다.

interface Store {
a: number;
b: number;
increaseA: () => void;
}
const useStore = create<Store>((set) => ({
a: 0,
b: 0,
increaseA: () => set((state) => ({ a: state.a + 1 })),
}));
function Foo() {
const a = useStore((state) => state.a);
const increaseA = useStore((state) => state.increaseA);
return (
<>
<span>A: {a}</span>
<button onClick={increaseA}>Increase A</button>
</>
);
}
function Bar() {
const b = useStore((state) => state.b);
return (
<>
<span>B: {b}</span>
</>
);
}

Foo 컴포넌트에서 상태를 변경해도 Bar 컴포넌트에서 리렌더링이 일어나지 않는다. Bar 컴포넌트가 스토어에서 가져오는 원시 값이 변경되지 않았기 때문이다.

이번에는 Bar 컴포넌트에서 값을 가져오는 방식을 수정해 보자.

function Bar() {
const { b } = useStore((state) => state);
return (
<>
<span>B: {b}</span>
</>
);
}

Foo 컴포넌트에서 상태를 변경하면 Bar 컴포넌트에서도 리렌더링이 일어난다. Bar 컴포넌트에서 사용되지 않는 a라는 값만 변경되었는데 말이다. 대부분의 상황에서 의도하지 않은 결과다.

원인은 Foo 컴포넌트에서 상태를 변경할 때 객체의 참조가 변경되었기 때문이다.

Zustand는 selector에서 실행된 값을 Object.is로 비교하고 변경되었을 경우 리렌더링한다. 위 코드는 실제로 스토어의 객체 전체를 구독하고 있는 셈이다.

객체에서 값 구독하기

불필요한 리렌더링을 유발하지 않고 객체에서 값을 가져오려면 다음과 같이 사용하면 된다. 객체 전체를 구독하는 것이 아니라 컴포넌트에서 필요한 값만 구독하는 방법이다.

// Good 👍
const a = useStore((state) => state.a);
// Bad 👎
const { a } = useStore((state) => state);

그런데 객체에서 구독해야 하는 값이 많아지면 코드 가독성이 떨어진다.

이런 경우 Zustand에서 제공하는 useShallow라는 유틸리티 함수 이용할 수 있다.

// Hmm 🤔
const a = useStore((state) => state.a);
const b = useStore((state) => state.b);
const c = useStore((state) => state.c);
// Oh 😆
const { a, b, c } = useStore(useShallow((state) => ({ })));

앞서 Zustand는 Object.is를 통해 selector에서 실행된 값을 비교해서 리렌더링 여부를 결정한다고 했다. useShallow를 이용하면 실행된 값이 이전과 같으면 리렌더링하지 않도록 막아준다.

useShallow의 동작 원리

그렇다면 useShallow는 어떤 방식으로 동작할까?

아래는 Zustand의 useShallow 구현 코드다.

react/shallow.ts
import React from 'react';
import { shallow } from '../vanilla/shallow.ts';
export function useShallow<S, U>(selector: (state: S) => U): (state: S) => U {
const prev = React.useRef<U>();
return (state) => {
const next = selector(state);
return shallow(prev.current, next) ? (prev.current as U) : (prev.current = next);
};
}

prev는 이전 값을 저장하고 리렌더링 시에도 값을 유지하기 위해서 useRef로 선언되었다.

useShallow에서 반환하는 함수 (state) => { ... }는 useShallow가 호출될 때의 prev와 selector를 참조하는 클로저다. 클로저가 없다면 useShallow 함수가 호출될 때마다 prev가 새로 선언돼서 이전 값을 잃어버린다. 이를 막기 위해서 클로저로 동일한 prev 객체를 참조하도록 했다.

(state) => { ... } 내부에서는 갱신된 상태를 selector를 통해서 next에 저장한다. 이후 shallow 함수를 통해 prev와 next를 비교해서 값이 같다면 기존 값을 반환하고, 다르다면 prev에 next를 저장하고 저장된 값을 반환한다.

만약 비교한 값이 같아서 prev를 반환했다면 기존 객체의 참조를 그대로 반환한 것이기 때문에 이를 구독하고 있는 컴포넌트에서 리렌더링이 일어나지 않는다.

관련 글