결론
- useSelector의 두번째 인자에는 전/후 상태값을 비교하는 equalityFn을 넣을 수 있다.
- 아무것도 안넣으면 refEquality가 들어간다. refEquality는 a === b 이런식의 간단한 비교만 한다.
- 전/후 비교해서 값이 달라진 경우에는 re-render가 발생한다
- 그렇다고 무조건 equalityFn을 사용하기 보다는 가능한 객체를 return하는걸 피하고 primitive값으로 좁히는게 성능상 좋다. 왜냐하면 (useSelector로 구독을 했기 때문에) action이 dispatch될때마다 상관없는 부분들이 다시 호출되기 때문이다.
- 참고로 react-redux는 객체의 얕은 비교를 해주는 shallowEqual을 export해놨기 때문에, 필요하면 가져다 쓸 수 있다.
re-render
action이 dispatch되었을때 useSelector를 사용한 컴포넌트는 selector함수가 return하는 값의 전후 상태를 비교해서 조건적으로 re-render시킨다. 전과 상태가 같다면 re-render가 발생하지 않고, 다르다면 다시 그리는 것이다.
// App.jsx
import ChildA from "./ChildA";
import ChildB from "./ChildB";
export function App() {
return (
<>
<ChildA />
<ChildB />
</>
);
}
// ChildA.jsx
import { useDispatch, useSelector } from "react-redux";
import ChildAA from "./ChildAA";
import createAction from "./redux/createAction";
import ACTION_TYPE from "./redux/actions";
function ChildA() {
console.log("ChildA is Rendered");
const dispatch = useDispatch();
const a = useSelector((state) => state.aObj.a);
const handleClick = () => {
dispatch(createAction(ACTION_TYPE.ACTION_ONE, a + 1));
};
return (
<div>
This is ChildA
<button type="button" onClick={handleClick}>
UPUP
</button>
<ChildAA />
</div>
);
}
export default ChildA;
// ChildAA.jsx
import ChildAAA from "./ChildAAA";
function ChildAA() {
console.log("ChildAA is Rendered");
return (
<div>
This is ChildAA
<ChildAAA />
</div>
);
}
export default ChildAA;
// ChildAAA.jsx
function ChildAAA() {
console.log("ChildAAA is Rendered---");
return <div>This is ChildAAA</div>;
}
export default ChildAAA;
// ChildB.jsx
import { useDispatch, useSelector } from "react-redux";
import createAction from "./redux/createAction";
import ACTION_TYPE from "./redux/actions";
function ChildB() {
console.log("----- ChildB is Rendered -----");
const dispatch = useDispatch();
const b = useSelector((state) => state.bObj.b);
const handleClick = () => {
dispatch(createAction(ACTION_TYPE.ACTION_TWO, b + 1));
};
return (
<div>
This is ChildB
<button type="button" onClick={handleClick}>
UPUP
</button>
</div>
);
}
export default ChildB;


useSelector의 두번째 인자에 비교함수(equalityFn)를 넣어주지 않았지만 기본적으로 useSelector안에서 refEquality로 이전 값과 비교를 해주기 때문에 ChildB는 re-render되지 않았다.
refEquality란 무엇인가?
const refEquality = (a, b) => a === b
이런 심플한 함수이다.
다시말해서, const b = useSelector((state) => state.bObj.b);
이런 상태에서 aObj.a의 값을 변경하는 action을 dispatch 했다 치다손, ChildB컴포넌트 입장에서는 이전값(state.bObj.b)과 비교해서 달라진것이 없기 때문에 re-render가 발생하지 않는다.
그럼 re-render를 발생시키려면 어떻게 해야할까? (좋은 일은 아니지만..!)
// ChildA
const { a } = useSelector((state) => state.aObj);
// ChildB
const { b } = useSelector((state) => state.bObj);
이렇게 하면 된다. dispatch후에 reducer를 거치면서 state는 새로 만들어지고, 이에 따라 aObj와 bObj도 새로운 객체가 할당된다. 그래서 useSelector가 제공하는 refEquality를 써도 다른 값으로 인식하기 때문에 re-render가 된다.
re-render를 막으려면?
refEquality 대신 비교하는 함수를 넣어주면 된다. useSelector의 두번째 인자에 내가 만든 equalityFn을 넣어주면 된다.
// ChildA
const { a } = useSelector(
(state) => state.aObj,
(prev, current) => {
return prev.a === current.a;
}
);
// ChildB
const { b } = useSelector(
(state) => state.aObj,
(prev, current) => {
return prev.b === current.b;
}
);
그런데 react-redux가 친절하게도 shallowEqual이라는 함수를 만들어 놓았다.
function shallowEqual(objA: any, objB: any) {
if (is(objA, objB)) return true
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
if (keysA.length !== keysB.length) return false
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
) {
return false
}
}
return true
}
객체안에 객체가 있는경우는 제대로 비교하지 못하지만, 객체안에 primitive값들만 있다면 의도한대로 비교를 해준다. 만약에 객체안에 객체가 있는 경우에 비교를 해야 한다면, 직접 만들어 줘야 한다.
성능 이슈
equalityFn은 불필요한 re-render를 막는다는 점에서 큰 이점이 있지만, 자식 요소가 없는 경우에는 안쓰는게 나을수도 있다. 아니면 useSelector로 객체를 받지 말고 가급적이면 객체를 쪼개서 primitive값을 받는게 성능이 더 좋다.
const name = useSelector((state) => state.person.name);
const age = useSelector((state) => state.person.age);
const hobby = useSelector((state) => state.person.hobby);
왜냐하면 person이랑 관련이 없는 action이 dispatch되었을때에도 selector함수와 equalityFn이 호출되기 때문이다. 다시 말해서, 위의 방법을 쓰나 equalityFn을 쓰나 re-render를 방지하는건 동일하지만 가급적이면 복잡한(시간이 오래걸리는) equalityFn의 사용를 지양하고 refEquality만 체크하는게 성능상 이점이 있다.
한 걸음 더!
결론
- 컴포넌트에서 useSelector사용
- 컴포넌트 rendering되면서 useSelector 실행함
- useSelector안에서 subscribe(handleStoreChange) 이런식으로 구독됨(컴포넌트가 구독된게 아니라 handleStoreChange라는 함수가 구독됨)
- 구독이라 함은 store가 변경되었을때 redux가 구독자들을 쨔라락 호출해줌
- 아무튼, dispatch action 발생
- (안중요) reducer를 거쳐서 store의 state가 갈아 끼워짐
- 구독자들에게 알람
- handleStoreChange호출
- 이전에 selector를 호출해서 받아온 값이랑 지금 selector를 호출해서 받아온 값이랑 비교해서 다르면 forceUpdate호출
- (안중요)
- (안중요) 여기서 비교 함수(equalityFn)은 우리가 넘겨줄 수도 있고, 안넘겨주면 기본으로 refEquality check를 해줌. 다만! 꼭 함수를 넣어줘야 함!
- 아무튼, forceUpdate가 호출되면 re-render발생
심플하게 정리하면 action dispatch하면 값 비교 후에 re-render시켜 버리거나 안하거나 한다.
useSelector의 모습
function useSelector(
selector,
equalityFn = refEquality
): {
const { store, subscription, getServerState } = useReduxContext()!
const selectedState = useSyncExternalStoreWithSelector(
subscription.addNestedSub, // 구독하는 함수
store.getState,
getServerState || store.getState,
selector, // 우리가 넘겨준 selector함수
equalityFn // 우리가 넘겨준 참조 동일성 체크 함수
)
return selectedState
}
재밌는점은 useState를 사용하지 않는다는 것이다. 분명히 action을 dispatch하면 re-render가 발생 했는데, 내부적으로 useState가없다.
그런데 정말 없는것일까? useSyncExternalStoreWithSelector를 살펴보자!
(참고로 아래 코드에 나오는 타입은 Typescript가 아니라 Flow입니다)
use_sync_external_store_with_selector의 모습
// Same as useSyncExternalStore, but supports selector and isEqual arguments.
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (() => void) => () => void, // 아까 넘겨준 구독하는 함수. 이 함수의 인자로 구독자를 넣는다.
getSnapshot: () => Snapshot, // 이게 아까 넘겨준 store.getState()
getServerSnapshot: void | null | (() => Snapshot),
selector: (snapshot: Snapshot) => Selection,
isEqual?: (a: Selection, b: Selection) => boolean,
): Selection {
// Use this to track the rendered snapshot.
const instRef = useRef(null);
// store.getState로 가져온 state를 selector에 넣어 호출해서 결과값을 가져오는 함수.
// (최적화 관련) 내부적으로 이전 값을 기억해서 값이 같으면 이전 값을 돌려준다
// 이전값을 돌려주면 useSyncExternalStore안에서 forceUpdate가 발생하지 않는다.
// 왜냐하면 기본적으로 useState는 Object.is를 사용해서 이전값과 같다면 re-render를 하지 않기 때문이다.
const getSelection = useMemo(() => {
const memoizedSelector = nextSnapshot => {
// We may be able to reuse the previous invocation's result.
const prevSnapshot: Snapshot = (memoizedSnapshot: any);
const prevSelection: Selection = (memoizedSelection: any);
// The snapshot has changed, so we need to compute a new selection.
const nextSelection = selector(nextSnapshot);
// If a custom isEqual function is provided, use that to check if the data
// has changed. If it hasn't, return the previous selection. That signals
// to React that the selections are conceptually equal, and we can bail
// out of rendering.
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
return prevSelection;
}
return nextSelection;
};
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
return getSnapshotWithSelector;
}, [getSnapshot, selector, isEqual]);
const value = useSyncExternalStore(
subscribe,
getSelection,
);
return value;
}
이번에는 subscribe와 getSelection을 useSyncExternalStore에 넘겨준다.
useSyncExternalstore는 뭘까?
export function useSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
): T {
// 원문
// Read the current snapshot from the store on every render. Again, this
// breaks the rules of React, and only works here because of specific
// implementation details, most importantly that updates are
// always synchronous.
// 내 생각
// 매 랜더링마다 selector(store.getState())로 결과값을 가져온다.
const value = getSnapshot();
// 원문
// Because updates are synchronous, we don't queue them. Instead we force a
// re-render whenever the subscribed state changes by updating an some
// arbitrary useState hook. Then, during render, we call getSnapshot to read
// the current value.
//
// Because we don't actually use the state returned by the useState hook, we
// can save a bit of memory by storing other stuff in that slot.
//
// To implement the early bailout, we need to track some things on a mutable
// object. Usually, we would put that in a useRef hook, but we can stash it in
// our useState hook instead.
//
// To force a re-render, we call forceUpdate({inst}). That works because the
// new object always fails an equality check.
// 핵심은 forceUpdate를 호출하면 re-render가 무조건 일어난다는 것이다.
// 왜냐하면 react는 forceUpdate즉, setState를 했을때 이전 값과 변한 이후값을 비교(Object.is)값이 다른 경우에만 re-render르 하는데
// 아래 나오겠지만 forceUpdate를 호출할때 새로운 객체를 넣기 때문에 언제나 Object.is는 false를 리턴해서 re-render가 되는것이다.
const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
// 원문
// Track the latest getSnapshot function with a ref. This needs to be updated
// in the layout phase so we can access it during the tearing check that
// happens on subscribe.
// 여기서 tearing이라는 말이 나온다. tearing은 UI와 store의 상태가 일치하지 않는 상황을 의미한다.
// concurrent mode가 아닌 상황에서는 rendering이 동기적으로 일어나서 store의 상태와 UI가 일치하지 않는 경우가 없다.
// 다시 말하자면, 모든 렌더링이 끝나고 -> store의 상태가 변하고 -> 다시 모든 렌더링이 끝나고..이렇게 되기 때문에 상태가 항상 UI에 잘 반영이 된다.
// 그러나 concurrent mode에서는 rendering하는 와중에 잠깐 멈추고 store의 state가 바뀔 수도 있기 때문에 이렇게 화면에 그려지기 직전에
// 다시 한번 값이 변했는지 체크하고 forceUpdate를 해주는 것이다.
useLayoutEffect(() => {
inst.value = value;
inst.getSnapshot = getSnapshot;
// Whenever getSnapshot or subscribe changes, we need to check in the
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst});
}
}, [subscribe, value, getSnapshot]);
useEffect(() => {
// Check for changes right before subscribing. Subsequent changes will be
// detected in the subscription handler.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst});
}
const handleStoreChange = () => {
// TODO: Because there is no cross-renderer API for batching updates, it's
// up to the consumer of this library to wrap their subscription event
// with unstable_batchedUpdates. Should we try to detect when this isn't
// the case and print a warning in development?
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceUpdate({inst});
}
};
// Subscribe to the store and return a clean-up function.
// 여기가 제일 중요한 부분이다.
// 구독을 하고 store의 state가 업데이트 되었을떄 여기 구독된 함수(handleStoreChange)를 redux가 호출한다!
return subscribe(handleStoreChange);
}, [subscribe]);
return value;
}
function checkIfSnapshotChanged(inst) {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !is(prevValue, nextValue);
} catch (error) {
return true;
}
}
찾았다! return subscribe(handleStoreChange);
여기가 핵심으로 보인다. forceUpdate를 하는 handleStoreChange함수를 subscribe했다. 다시 말해서 store가 바뀌었을때 결국 (조건적으로) handleStoreChange가 호출되고 re-render된다는것이다!
참고