useSyncExternalStore의 추상화 된 getSnapshot 구독 및 렌더링 알고리즘 동기화

pengooseDev·2024년 4월 16일
0
post-thumbnail

useSyncExternalStore의 첫 번째 매개변수는 getSnapshot이라는 콜백함수를 전달받는다. 상태의 변경을 구독하고자 하는 state를 전달해야 한다.

import { useSyncExternalStore } from 'react';

const externalState = { value: 42 };
const listeners = new Set();

const getSnapshot = () => {
  return externalState.value;
};

const subscribe = (listener: () => void) => {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
};

const Component = () => {
  const state = useSyncExternalStore(subscribe, getSnapshot);
  return <div>Current value: {state}</div>;
}

하지만, 위 방식에서 객체 내부에서 복잡한 비즈니스 로직을 추상화 하는 경우. 하나의 문제점이 발생한다.


state가 너무 많은 프로퍼티들을 가질 수 있다.

React에서 제공하는 useSyncExternalStore가 state 자체를 구독하는 방식은 굉장히 직관적이다. 하지만 사용하고자 하는 모든 값을 모두 state로 추상화해야 한다는 문제가 있다.
예를들어, 현재 유저가 재생중인 음악을 알고자한다면 playlist와 index 두 개의 state로 원하는 값을 얻을 수 있지만, useExternalStore의 경우 state에 currentMusic이라는 필드를 추상화해야 한다.


해결책1 (추상화 된 값 반환)

이러한 문제는 getSnapshot 콜백함수가 state 그 자체를 반환하는 것이 아닌 추상화 된 상태를 반환하는 방식으로 해결할 수 있다.

// 이해를 돕기 위한 의사 코드
const playerState = {
    playlist: Music[];
    index: number;
}

export abstract class StateManager<T> {
  constructor(protected state: T) {}
  
  public selectors = {
    playlist: this.state.playlist,
    currentMusic: this.state.playlist[this.state.index],
  };

  // ... codes
  public getSnapshot() {
    return this.selectors; // ⛳️ this.state가 아닌 추상화된 selectors 반환
  }
}

문제점 1: 고정된 메모리 주소

하지만 해결책 1의 방식은 useSyncExternalStore의 re-rendering 알고리즘에 동기화되지 못한다. 내부 state가 변경되어도 selectors의 힙 메모리 주소 자체는 변경되지 않기 때문이다.


해결책 2: 변경된 메모리 주소 반환

selectors의 값들이 state를 그대로 구독하는 것이 아니라 호출 시점에서 변경된 state를 반환하도록 한다.

class Playlist extends StateManager<PlaylistStatus> {
  selectors = {
    playlist: () => this.state.playlist,
    currentMusic: () => this.state.playlist[this.state.index],
  };

문제점 2: re-render 알고리즘과 동기화되지 않음

하지만 여전히 getSnapshot의 코드를 변경하지 않는다면 문제점1에서 다룬 본질적인 문제는 해결되지 않는다. selectors 프로퍼티들의 함수 자체의 메모리 주소는 변경되지 않기 때문이다.

이쯤에서 추상화된 코드에 대해 살펴보자.

언제 re-render(forceUpdate)되는가?

아래는 useSyncExternalStore의 구현체이다. re-render의 로직은 react-hook-form의 비제어 컴포넌트를 제어 컴포넌트로 동기화하는 로직과 비슷하다.

export function useSyncExternalStore<T>(
  subscribe: (() => void) => () => void,
  getSnapshot: () => T,
  getServerSnapshot?: () => T,
): T {
  // ...codes
  const value = getSnapshot();
  const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}}); // ⛳️

  // ⛳️
  useLayoutEffect(() => {
    inst.value = value;
    inst.getSnapshot = getSnapshot;
    if (checkIfSnapshotChanged(inst)) {
      forceUpdate({inst});
    }
  }, [subscribe, value, getSnapshot]);

  // ⛳️
  useEffect(() => {
    // Check for changes right before subscribing. Subsequent changes will be
    if (checkIfSnapshotChanged(inst)) {
      // Force a re-render.
      forceUpdate({inst}); 
    }

    const handleStoreChange = () => {
      if (checkIfSnapshotChanged(inst)) {
        // Force a re-render.
        forceUpdate({inst});
      }
    };
    return subscribe(handleStoreChange);
  }, [subscribe]);

  useDebugValue(value);
  return value;
}

function checkIfSnapshotChanged<T>(inst: {
  value: T,
  getSnapshot: () => T,
}): boolean {
  const latestGetSnapshot = inst.getSnapshot;
  const prevValue = inst.value; // ⛳️
  try {
    const nextValue = latestGetSnapshot(); // ⛳️
    return !is(prevValue, nextValue); // ⛳️
  } catch (error) {
    return true;
  }
}
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) 
  );
}

const objectIs: (x: any, y: any) => boolean =
  typeof Object.is === 'function' ? Object.is : is;  // ⛳️

export default objectIs;

useSyncExternalStore의 diff 알고리즘은 하나의 state를 구독하도록 추상화 되어있다. 따라서 Object.is(objetIs)를 사용하여 re-rendering의 여부를 결정하며, 해당 알고리즘을 재사용하기는 어렵다. 필자가 추상화 한 selector의 값들은 함수가 반환하는 객체이기 때문에 메모리 주소가 계속 변경되기 때문이다.


해결책 2: deepEqual

따라서, getSnapshot 내부에서 deepEqual을 추상화하여 실질적인 값의 변화가 발생한 경우, 조건부로 값을 반환해야 한다.

  • 휴리스틱 알고리즘을 학습하던 도중, state의 depth가 보통 크기 않기 때문에 득보단 실이 많다고 판단하여 간단하게 추상화한 뒤, 계속해서 발전시키기로 했다.

deepEqual

const deepEqual = (obj1: any, obj2: any, cache = new WeakMap()): boolean => {
  const isSameValue = obj1 === obj2;
  if (isSameValue) {
    return true;
  }

  const isSameType = typeof obj1 === typeof obj2;
  if (!isSameType) {
    return false;
  }

  const isEitherNull = obj1 === null || obj2 === null;
  if (isEitherNull) {
    return false;
  }

  const isEitherObject = typeof obj1 === 'object' && typeof obj2 === 'object';
  if (!isEitherObject) {
    return false;
  }

  const isCircularReference = cache.has(obj1) && cache.get(obj1) === obj2;
  if (isCircularReference) {
    return true;
  }

  const obj1Keys = Object.keys(obj1);
  const obj2Keys = Object.keys(obj2);

  const isSameLength = obj1Keys.length === obj2Keys.length;
  if (!isSameLength) {
    return false;
  }

  const obj2KeysSet = new Set(obj2Keys);
  cache.set(obj1, obj2);

  for (const key of obj1Keys) {
    if (!obj2KeysSet.has(key) || !deepEqual(obj1[key], obj2[key], cache)) {
      return false;
    }
  }

  return true;
};

적용

stateManager

import { deepEqual } from '@/utils/deepEqual';

export abstract class StateManager<T> {
  public listeners: Set<() => void> = new Set();

  private lastSnapshot: any = null;

  constructor(protected state: T) {}

  protected computeSnapshot(): StateManager<T>['selectors'] {
    const selectors = Object.fromEntries(
      Object.entries(this.selectors).map(([key, selectorFn]) => {
        const getSelector = selectorFn as () => any;
        const selector = getSelector();

        return [key, selector];
      })
    ) as StateManager<T>['selectors'];

    return selectors;
  }

  public getSnapshot(): StateManager<T>['selectors'] {
    const currentSnapshot = this.computeSnapshot();

    const isInitial = this.lastSnapshot === null;
    const isChanged = !deepEqual(this.lastSnapshot, currentSnapshot);

    if (isInitial || isChanged) this.lastSnapshot = currentSnapshot;

    return this.lastSnapshot;
  }

  public set(newState: Partial<T>) {
    this.state = { ...this.state, ...newState };
    this.listeners.forEach((listener) => listener());
  }

  public subscribe(listener: () => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  abstract selectors: {
    [K in keyof Partial<T>]: any;
  };

  abstract actions: {
    [key: string]: (payload: any) => void;
  };
}

useManager

import { useSyncExternalStore } from 'react';
import { StateManager } from './stateManager';

export const useManager = <T extends StateManager<any>>(manager: T) => {
  const selectors = useSyncExternalStore(
    manager.subscribe.bind(manager),
    manager.getSnapshot.bind(manager)
  ) as {
    [K in keyof T['selectors']]: ReturnType<T['selectors'][K]>;
  };

  const actions = manager.actions as {
    [K in keyof T['actions']]: T['actions'][K];
  };

  return { selectors, actions };
};

적용 예시(Playlist)

import { PlaylistStatus } from '@/types/playlist';
import { StateManager } from './stateManager';
import { Music, PlaylistProps } from '@/types';

class Playlist extends StateManager<PlaylistStatus> {
  selectors = {
    playlist: () => this.state.playlist,
    currentMusic: () => this.state.playlist[this.state.index],
  };

  actions = {
    play: (index: number) => {
      this.set({ index });
    },

    next: () => {
      const { playlist, index } = this.state;
      this.set({ index: this.getIncresedIndex({ index, playlist }) });
    },

    prev: () => {
      const { playlist, index } = this.state;
      this.set({ index: this.getDecresedIndex({ index, playlist }) });
    },

    add: (music: Music) => {
      const { playlist } = this.state;

      if (playlist.some(({ id }) => id === music.id)) return;

      this.set({ playlist: [...playlist, music] });
    },

    remove: (music: Music) => {
      const { playlist, index } = this.state;
      const targetIndex = playlist.findIndex((item) => item.id === music.id);
      const isTargetAfterCurrent = targetIndex > index;

      const newPlaylist = playlist.filter((item) => item.id !== music.id);
      const newIndex = isTargetAfterCurrent
        ? index
        : this.getDecresedIndex({ index, playlist });

      this.set({
        playlist: newPlaylist,
        index: newIndex,
      });
    },
  };

  private isEmpty(playlist: Music[]) {
    return playlist.length === 0;
  }

  private isFirstMusic(index: number) {
    return index === 0;
  }

  private isLastMusic({ index, playlist }: PlaylistProps) {
    return index === playlist.length - 1;
  }

  private getIncresedIndex({ index, playlist }: PlaylistProps) {
    const isLastMusic = this.isLastMusic({ index, playlist });
    if (isLastMusic) return 0;

    return index + 1;
  }

  private getDecresedIndex({ index, playlist }: PlaylistProps) {
    const isEmpty = this.isEmpty(playlist);
    if (isEmpty) return 0;

    const isFirstMusic = this.isFirstMusic(index);
    if (isFirstMusic) return playlist.length - 1;

    return index - 1;
  }
}

const initialState: PlaylistStatus = {
  playlist: [],
  index: 0,
};

export const playlistManager = new Playlist(initialState);

jotai에서 사용했던 도메인 별로 상태를 작게 쪼개, DI를 사용하는 방식도 추후에 구현해볼 예정이다.

0개의 댓글