이전의 React-redux에서 Context API의 렌더링이슈를 해결한 방법

react-redux는 구독/발행 패턴으로 구현되었다.

React와 사용하기 위해 ContextAPI를 사용한다.

Context API는 렌더링마다 새 객체를 생성하게 되어 하위 컴포넌트들이 모두 리렌더링 되는 이슈가 있다.

React-redux에서는 shallowEqual이라는 내장함수를 제공하는데, 이를 통해 Context API의 렌더링 이슈를 해결할 수 있다.

동작 방식

react-redux에서는 useSelector의 equalityFn은 이전값과 다음값을 비교하여 true일경우 리렌더링을 하지 않고 false가 나오면 리렌더링을 한다.

리렌더링 방식은 다음과 같다.

useSelectorWithStoreAndSubscription Hook에는 강제로 렌더링을 트리거하는 forceRender함수가 존재한다.

그리고 store또는 subscription이 변경될 경우 실행되는 useEffect Hook이 존재한다.

이 Hook에서는 parameter로 전달받은 equalityFn을 사용하여 이전값과 업데이트될 값을 비교한다.

1
type equalityFn = (left: any, right: any) => booleant;

즉 equalityFn에서 true를 반환할 경우 아무 작업도 하지 않고 useEffect를 탈출한다. 값이 다르다면 state를 업데이트 한 후 forceRender 함수를 실행한다.

여기서 state는 useRef를 통해서 관리되고 있기 떄문에 forceRender 함수를 실행하여 강제로 렌더링을 하지 않는다면 단순 state의 변화로 렌더링이 일어나지 않는다.

Object.is

Object는 형변환이 일어나지 않는 얕은비교를 한다.

즉 형변환 없이 같은 값인지를 반환한다.

예를들어 -0과 +0또한 다른값으로 한다는 이야기이다.

mdn의 폴리필은 다음과 같다.

1
2
3
4
5
6
7
8
9
if (!Object.is) {
Object.is = function (x, y) {
if (x === y) {
return x !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
};
}

ShallowEqual

ShallowEqual은 객체의 depth가 1인 값들을 모두 비교한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y;
} else {
return x !== x && y !== y;
}
}

function shallowEqual(objA, objB) {
if (is(objA, objB)) return true;
// is를 사용한 얕은비교
if (
typeof objA !== "object" ||
objA === null ||
typeof objB !== "object" ||
objB === null
) {
return false;
}
// 얕은 비교가 끝났는데 객체가 아니거나 null이면 false
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
// 객체의 key를 가져옴
if (keysA.length !== keysB.length) return false;
// key의 길이가 다를경우 false
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
// objB에 keysA[i]가 존재하지 않을 경우
// objA[keysA[i]]와 objB[keysA[i]]가 is로 비교하였을 때 다른 값일 경우
// false
) {
return false;
}
}
return true;
}

ShallowEqual을 사용하여 객체의 reference의 변경만으로 렌더링을 트리거하지 않도록 할 수 있게 되었다.

reat-redux는 현재 React 18부터 제공되는 useSyncExternalStoreHook을 사용하도록 변경되었다.

하지만 구독/발행 패턴에서 기존의 ContextAPI의 렌더링이슈를 해결한 방법에 대해서 다룰 가치가 있다고 생각하여 다루어보았다.

위에서 언급한 useSelectorWithStoreAndSubscription의 전체 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function useSelectorWithStoreAndSubscription(
selector,
equalityFn,
store,
contextSub
) {
const [, forceRender] = useReducer((s) => s + 1, 0);
const subscription = useMemo(
() => new Subscription(store, contextSub),
[store, contextSub]
);
const latestSelector = useRef();
const latestSelectedState = useRef();
let selectedState;
if (selector !== latestSelector.current) {
// 첫 상태
selectedState = selector(store.getState());
} else {
// 첫 상태가 아닐경우
selectedState = latestSelectedState.current;
}
useEffect(() => {
// 렌더링마다 참조를 변경
latestSelector = selector;
latestSelectedState = selectedState;
});

useEffect(() => {
function checkForUpdates() {
const newSelectedState = latestSelector.current(store.getState());
if (equalityFn(newSelectedState, latestSelectedState.current)) {
// 같을경우 렌더링 안함
return;
}
latestSelectedState.current = newSelectedState;
// 다를경우 state를 업데이트하고 렌더링을 트리거
forceRender({});
}
subscription.onStateChange = checkForUpdates;
subscription.trySubscribe();
checkForUpdates();
return () => subscription.tryUnsubscribe();
}, [store, subscription]);
return selectedState;
}

useSyncExternalStore가 개선한 사항들은 여기여기서 볼 수 있다.

다음에는 zustand 코드를 보고 ContextAPI를 사용하지 않고 상태관리를 한 방법에 대해서 알아보려고 한다.

댓글