Deep dive in useselector

D

결론

  1. useSelector의 두번째 인자에는 전/후 상태값을 비교하는 equalityFn을 넣을 수 있다.
  2. 아무것도 안넣으면 refEquality가 들어간다. refEquality는 a === b 이런식의 간단한 비교만 한다.
  3. 전/후 비교해서 값이 달라진 경우에는 re-render가 발생한다
  4. 그렇다고 무조건 equalityFn을 사용하기 보다는 가능한 객체를 return하는걸 피하고 primitive값으로 좁히는게 성능상 좋다. 왜냐하면 (useSelector로 구독을 했기 때문에) action이 dispatch될때마다 상관없는 부분들이 다시 호출되기 때문이다.
  5. 참고로 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;

실행해보기

ChildB는 render되지 않는다

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만 체크하는게 성능상 이점이 있다.

한 걸음 더!

결론

  1. 컴포넌트에서 useSelector사용
  2. 컴포넌트 rendering되면서 useSelector 실행함
  3. useSelector안에서 subscribe(handleStoreChange) 이런식으로 구독됨(컴포넌트가 구독된게 아니라 handleStoreChange라는 함수가 구독됨)
  4. 구독이라 함은 store가 변경되었을때 redux가 구독자들을 쨔라락 호출해줌
  5. 아무튼, dispatch action 발생
  6. (안중요) reducer를 거쳐서 store의 state가 갈아 끼워짐
  7. 구독자들에게 알람
  8. handleStoreChange호출
  9. 이전에 selector를 호출해서 받아온 값이랑 지금 selector를 호출해서 받아온 값이랑 비교해서 다르면 forceUpdate호출
  10. (안중요) 여기서 forceUpdate는 const [_, forceUpdate] = useState({}); 이거임. 즉 호출하면 re-render됨.
  11. (안중요) 여기서 비교 함수(equalityFn)은 우리가 넘겨줄 수도 있고, 안넘겨주면 기본으로 refEquality check를 해줌. 다만! 꼭 함수를 넣어줘야 함!
  12. 아무튼, 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된다는것이다!

참고

Add Comment