Context API와 전역 상태 관리 라이브러리에 대해 고찰해보고 직접 구현해봅니다.

리액트 상태 관리에 사용되는 여러 방법

리액트를 상태 관리에 사용할 수 있는 방법은 크게 두가지가 있습니다.

  1. state, props, Context API 등의 internal store를 사용하는 방법
  2. Redux, MobX 등의 external store를 사용하는 방법

전역 상태 관리가 필요한 경우, 외부 라이브러리의 도움을 받지 않으려고 한다면 Context API가 거의 유일한 방법입니다. 그러나, Context API는 전역 상태 관리에 그냥 사용하기엔 아쉬운 점이 많습니다.

전역 상태 관리에 Context API를 사용하기 까다로운 이유

Context API는 상태 관리를 위한 기능이라기 보다는 의존성 주입을 위한 API지만, 외부 라이브러리의 도움 없이 간단하게 지역적인 상태 관리를 구현하기에 좋은 도구입니다.

const DEFAULT_USER = {
  name: "unknown",
  age: 0,
};
 
const UserContext = React.createContext({
  ...DEFAULT_USER,
  setUser: () => void, // setter를 context에 담습니다.
});
 
const UserContextProvider = ({ children }) => {
  const [user, setUser] = React.useState(DEFAULT_USER);
 
  const value = {
    ...user,
    setUser,
  };
 
  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
};
const DEFAULT_USER = {
  name: "unknown",
  age: 0,
};
 
const UserContext = React.createContext({
  ...DEFAULT_USER,
  setUser: () => void, // setter를 context에 담습니다.
});
 
const UserContextProvider = ({ children }) => {
  const [user, setUser] = React.useState(DEFAULT_USER);
 
  const value = {
    ...user,
    setUser,
  };
 
  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
};

위와 같이 Context Provider로 감싸진 컴포넌트는 Context를 통해 상태를 사용할 수 있습니다.

const UserDisplay = () => {
  const { name, age, setUser } = React.useContext(UserContext)
 
  return (
    <div>
      <p>{name}</p>
      <p>{age}</p>
      <button onClick={() => setUser({ name: "John", age: 20 })}>Set User</button>
    </div>
  )
}
const UserDisplay = () => {
  const { name, age, setUser } = React.useContext(UserContext)
 
  return (
    <div>
      <p>{name}</p>
      <p>{age}</p>
      <button onClick={() => setUser({ name: "John", age: 20 })}>Set User</button>
    </div>
  )
}

다만, Context API는 '잘 사용하려면' 약간 까다롭습니다. 위의 UserContextProvider에서 value 객체는 매 렌더링마다 새로 생성됩니다. Context Provider는 얕은 비교만 수행하기 때문에 해당 컨텍스트의 값이 바뀌지 않았더라도 컨텍스트를 참조하는 모든 컴포넌트가 리렌더링 됩니다. 따라서 UserContextProvider의 부모 컴포넌트가 리렌더링되면 관련 없는 컴포넌트의 불필요한 리렌더링이 발생할 수 있습니다.

이러한 이유로, Context Provider에 전달되는 값이 원시값이 아니라면 React.useMemo를 사용해 값을 캐싱하는 것이 좋습니다.

const UserContextProvider = ({ children }) => {
  const [user, setUser] = React.useState(DEFAULT_USER)
 
  const value = React.useMemo(
    () => ({
      ...user,
      setUser,
    }),
    [user],
  )
 
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>
}
const UserContextProvider = ({ children }) => {
  const [user, setUser] = React.useState(DEFAULT_USER)
 
  const value = React.useMemo(
    () => ({
      ...user,
      setUser,
    }),
    [user],
  )
 
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>
}

또는 React.memo를 써서 Context Provider 자체의 리렌더링을 막는것도 방법입니다.

그러나 문제는 여기서 끝나지 않습니다. 앞서 언급한대로 Context API는 컨텍스트의 값이 바뀌면 해당 컨텍스트를 참조하는 모든 컴포넌트를 리렌더링 합니다. 그래서 컨텍스트의 값 중 일부 프로퍼티만을 사용하는 경우 그 프로퍼티의 값이 바뀌지 않아도 리렌더링은 피할 수 없습니다.

const UserNameDisplay = () => {
  const { name } = React.useContext(UserContext)
 
  return (
    <div>
      <p>{name}</p>
    </div>
  )
}
const UserNameDisplay = () => {
  const { name } = React.useContext(UserContext)
 
  return (
    <div>
      <p>{name}</p>
    </div>
  )
}

위 컴포넌트는 UserContext에서 주는 값 중 name만을 사용하고 있음에도 불구하고, age만 변경되어도 리렌더링 됩니다.

따라서, Context API에 값이 바뀔 수 있는 object value를 사용할 경우 최대한 컨텍스트를 쪼개써야 합니다.

const UserNameContext = React.createContext({
  name: "unknown",
});
 
const UserAgeContext = React.createContext({
  age: 0,
});
 
const UserSetterContext = React.createContext({
  setUser: () => void,
});
const UserNameContext = React.createContext({
  name: "unknown",
});
 
const UserAgeContext = React.createContext({
  age: 0,
});
 
const UserSetterContext = React.createContext({
  setUser: () => void,
});

극단적으로 가면 이렇게도 쪼갤 수 있겠죠. 더 가면 원시값 자체만을 컨텍스트 값으로 쓸 수도 있을거고요. 불필요한 리렌더링은 피할 수 있게 됐지만 너무 많은 컨텍스트가 생성돼 복잡도가 올라가고 관리하기 어려워졌습니다.

이렇게 Context API만을 사용하여 '전역' 상태를 관리하는 데는 여러 제약이 있습니다. 그래서 대부분의 프로젝트는 Recoil이나 Redux, zustand 등의 외부 라이브러리를 사용합니다.

제 생각에 Context API는 이름 그대로 어떤 컴포넌트가 참조할 수 있는 컨텍스트라고 접근하는게 좋을 것 같습니다. 특정 상태나 값이 어떤 스코프로 제한되면서도, 그 스코프 안에서는 자유롭게 사용할 수 있는 값이라고 생각합니다. 해당 컴포넌트가 필요로 하는 의존성을 모아둘 수 있으므로 코드의 가독성이나 테스트 용이성을 높여주는 도구이기도 합니다.

전역 상태 관리 라이브러리를 직접 만들어보자

저는 항상 전역 상태 관리 라이브러리가 대체 어떻게 동작하는걸까 궁금했습니다. 그래서 이번 기회에 전역 상태 관리 라이브러리를 직접 구현해보면서 그 동작 원리를 이해하고자 했습니다. Recoil의 API와 코드를 참고했으며, 실제 동작 원리는 조금 다를 수 있습니다.

이름은 very-simple-store입니다.

구경하기

이하의 내용은 실제 구현된 코드를 설명을 위해 단순화한 것입니다.

제가 이해한 만큼만을 가지고 개발한 라이브러리입니다. 혹시 잘못된 부분이 있다면 알려주시면 감사하겠습니다.

전역 상태를 어디에 어떻게 담고 관리할 것인가

전역 상태는 온전히 라이브러리에 의해서만 관리될 수 있어야 하므로, useRef를 사용해 리액트 외부에 위치하도록 합니다. 이 객체를 store라고 부르겠습니다.

const store = React.useRef({
  state: new Map(),
})
const store = React.useRef({
  state: new Map(),
})

store는 Context API를 통해 리액트 컴포넌트들이 공유할 수 있도록 합니다.

const StoreContext = React.createContext(store)
 
const StoreRoot = ({ children }) => {
  const store = React.useRef({
    state: new Map(),
  })
  return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
}
const StoreContext = React.createContext(store)
 
const StoreRoot = ({ children }) => {
  const store = React.useRef({
    state: new Map(),
  })
  return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>
}

앱의 루트에 StoreRoot를 감싸주면 이 앱 어디에서든 접근할 수 있는 전역 상태가 준비됩니다.

const App = () => {
  return <StoreRoot>{/* ... */}</StoreRoot>
}
const App = () => {
  return <StoreRoot>{/* ... */}</StoreRoot>
}

전역 상태가 바뀔 때 컴포넌트를 어떻게 리렌더링할 것인가

전역 상태가 바뀌면 그 상태를 사용하는 컴포넌트만을 리렌더링 해야합니다. 이를 위해서는 두 가지가 필요합니다.

  1. 특정 전역 상태에 '구독'하는 기능
  2. 특정 전역 상태가 바뀌었을 때 '구독'한 컴포넌트를 리렌더링하는 기능
특정 전역 상태에 '구독'하는 기능

very-simple-store에서는 각각의 전역 상태를 하나의 노드로 보고, StoreNode라는 이름을 붙였습니다.

구독하는 컴포넌트를 관리하기 위해 이런 프로퍼티들을 가지도록 했습니다.

type StoreNode<T> = {
  key: string
  value: T
  subscribers: Set<() => void>
  subscribe: (callback: () => void) => () => void
  emitChange: () => void
}
type StoreNode<T> = {
  key: string
  value: T
  subscribers: Set<() => void>
  subscribe: (callback: () => void) => () => void
  emitChange: () => void
}

이제 StoreNode를 생성하는 함수인 addStoreNode를 만들어봅시다.

const addStoreNode = <T>(key: string, initialValue: T): StoreNode<T> => {
  const subscribers = new Set<() => void>()
  const value = initialValue
 
  const subscribe = (callback: () => void) => {
    subscribers.add(callback)
 
    return () => {
      subscribers.delete(callback)
    } // unsubscribe 함수를 반환합니다.
  }
 
  const emitChange = () => {
    subscribers.forEach((callback) => callback())
  }
 
  return {
    key,
    value,
    subscribers,
    subscribe,
    emitChange,
  }
}
const addStoreNode = <T>(key: string, initialValue: T): StoreNode<T> => {
  const subscribers = new Set<() => void>()
  const value = initialValue
 
  const subscribe = (callback: () => void) => {
    subscribers.add(callback)
 
    return () => {
      subscribers.delete(callback)
    } // unsubscribe 함수를 반환합니다.
  }
 
  const emitChange = () => {
    subscribers.forEach((callback) => callback())
  }
 
  return {
    key,
    value,
    subscribers,
    subscribe,
    emitChange,
  }
}

이렇게 하면 아래와 같이 간편한 API가 완성됩니다.

const storeNode = addStoreNode("userName", "Shi Woo, Park")
 
const callback = () => {
  console.log("userName이 바뀌었습니다.")
}
 
// 전역 상태에 구독하기
const unsubscribe = storeNode.subscribe(callback)
 
// 전역 상태 값 변경 후 구독자들에게 알리기
storeNode.emitChange()
 
// 구독 취소하기
unsubscribe()
const storeNode = addStoreNode("userName", "Shi Woo, Park")
 
const callback = () => {
  console.log("userName이 바뀌었습니다.")
}
 
// 전역 상태에 구독하기
const unsubscribe = storeNode.subscribe(callback)
 
// 전역 상태 값 변경 후 구독자들에게 알리기
storeNode.emitChange()
 
// 구독 취소하기
unsubscribe()
특정 전역 상태가 바뀌었을 때 '구독'한 컴포넌트를 리렌더링하는 기능

리액트에서 리렌더링을 트리거하는 가장 간단한 방법은 setState를 호출하는 것입니다. 저는 제가 원하는 타이밍에 리렌더링을 일으키고자 하는 것이므로, 강제로 가짜 setState를 호출하는 Hook을 만들었습니다.

const useForceUpdate = () => {
  const [, setState] = React.useState({})
  return React.useCallback(() => setState({}))
}
const useForceUpdate = () => {
  const [, setState] = React.useState({})
  return React.useCallback(() => setState({}))
}

대부분의 라이브러리는 사실 리액트에서 제공하는 useSyncExternalStore 훅을 사용하고 있습니다. 여기에서는 데이터 흐름을 직접 제어하며 원리를 이해하기 위해 강제로 리렌더링을 일으키는 훅을 사용합니다.

이제 저 훅을 사용해 StoreNode의 값을 사용하도록 도와주는 useStoreNodeGetter를 만들어봅시다.

const useStoreNodeGetter = <T>(storeNode: StoreNode<T>): T => {
  const forceUpdate = useForceUpdate()
  const storeRef = React.useContext(StoreContext) // 전역 상태가 담기는 객체 컨텍스트
 
  React.useEffect(() => {
    const store = storeRef.current
 
    if (!store.state.has(storeNode.key)) {
      // 아직 전역 상태로 등록되지 않은 노드라면
      // 지금 추가해줍니다.
      store.state.set(storeNode.key, storeNode)
    }
 
    const unsubscribe = storeNode.subscribe(forceUpdate)
 
    return unsubscribe
  }, [storeNode, forceUpdate, storeRef])
 
  return storeRef.current.state.get(storeNode.key)?.value ?? storeNode.value
}
const useStoreNodeGetter = <T>(storeNode: StoreNode<T>): T => {
  const forceUpdate = useForceUpdate()
  const storeRef = React.useContext(StoreContext) // 전역 상태가 담기는 객체 컨텍스트
 
  React.useEffect(() => {
    const store = storeRef.current
 
    if (!store.state.has(storeNode.key)) {
      // 아직 전역 상태로 등록되지 않은 노드라면
      // 지금 추가해줍니다.
      store.state.set(storeNode.key, storeNode)
    }
 
    const unsubscribe = storeNode.subscribe(forceUpdate)
 
    return unsubscribe
  }, [storeNode, forceUpdate, storeRef])
 
  return storeRef.current.state.get(storeNode.key)?.value ?? storeNode.value
}

forceUpdate가 실행되는 순간 이 Hook을 사용중인 컴포넌트는 리렌더링 됩니다. 19번째 줄에서 리턴되는 값은 리렌더링마다 다시 평가되므로 자연스럽게 최신 상태를 가져올 수 있습니다.

이번에는 StoreNode의 값을 변경하는 setter를 만들어주는 useStoreNodeSetter를 만들어봅시다.

const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      existingNode.value = newValue
      existingNode.emitChange() // 구독자들에게 알립니다.
    },
    [storeNode, storeRef],
  )
}
const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      existingNode.value = newValue
      existingNode.emitChange() // 구독자들에게 알립니다.
    },
    [storeNode, storeRef],
  )
}

selector 구현

selector는 전역 상태를 읽고, 그 상태를 가공하여 새로운 값을 만들어내는 함수입니다. very-simple-store에서는 이 selector를 StoreSelectorNode라는 이름으로 구현했습니다.

selector 등록

우선 전역 상태를 담고 있는 Store 객체에 selector를 추가할 수 있도록 합니다.

const store = React.useRef({
  state: new Map(),
  selectors: new Map(),
})
const store = React.useRef({
  state: new Map(),
  selectors: new Map(),
})

selector 관련 타입은 이렇게 선언했습니다.

export type Selector<T> = ({ get }: { get: <U>(node: StoreNode<U>) => U }) => T
 
export type SelectorNode<T> = {
  key: string
  selector: Selector<T>
  value: T
  _dependencies: Set<string>
  _subscribers: Set<() => void>
  subscribe: (callback: () => void) => () => void
  emitChange: () => void
}
export type Selector<T> = ({ get }: { get: <U>(node: StoreNode<U>) => U }) => T
 
export type SelectorNode<T> = {
  key: string
  selector: Selector<T>
  value: T
  _dependencies: Set<string>
  _subscribers: Set<() => void>
  subscribe: (callback: () => void) => () => void
  emitChange: () => void
}

selector가 의존하는 전역 상태를 _dependencies에 담고, selector의 리턴값을 value에 담습니다.

Selector<T>는 실제 selector 함수의 타입입니다. 인자에 get이라는 getter 함수를 전달해 selector가 의존하는 전역 상태를 가져올 수 있도록 합니다. selector의 실제 구현 코드에 별다른 제약을 가하지 않았기 때문에, 전역 상태가 변경됐을 때 동작해야 하는 로직은 사용단에서 자유롭게 넣을 수 있습니다. 또한 Store 외부에서 바뀌는 값은 selector 내부에서 자연스럽게 사용할 수 없게 되므로, selector의 모양을 순수 함수로 강제해 줄 수 있다는 장점도 덤으로 얻을 수 있습니다.

이제 StoreSelectorNode를 생성하는 함수인 addStoreSelectorNode를 만들어봅시다.

const addStoreSelectorNode = <T>(key: string, selector: Selector<T>): StoreSelectorNode<T> => {
  const _dependencies = new Set<string>()
  const _subscribers = new Set<() => void>()
 
  const emitChange = () => {
    _subscribers.forEach((callback) => {
      callback()
    })
  }
 
  const subscribe = (callback: () => void) => {
    _subscribers.add(callback)
 
    return () => {
      _subscribers.delete(callback)
    } // unsubscribe 함수를 반환합니다.
  }
 
  return {
    key,
    _dependencies,
    _subscribers,
    selector,
    value: undefined as T, // 나중에 selector를 실행하면서 값이 할당됩니다.
    emitChange,
    subscribe,
  }
}
const addStoreSelectorNode = <T>(key: string, selector: Selector<T>): StoreSelectorNode<T> => {
  const _dependencies = new Set<string>()
  const _subscribers = new Set<() => void>()
 
  const emitChange = () => {
    _subscribers.forEach((callback) => {
      callback()
    })
  }
 
  const subscribe = (callback: () => void) => {
    _subscribers.add(callback)
 
    return () => {
      _subscribers.delete(callback)
    } // unsubscribe 함수를 반환합니다.
  }
 
  return {
    key,
    _dependencies,
    _subscribers,
    selector,
    value: undefined as T, // 나중에 selector를 실행하면서 값이 할당됩니다.
    emitChange,
    subscribe,
  }
}

아직 selector를 실행하지는 않았으므로 valueundefined입니다. 이 부분에서 value의 초기값을 어떻게 처리할까 고민했는데, Selector Node를 선언한 것 만으로 selector가 실행된다면 문제가 생길 수 있다고 생각해 이렇게 처리했습니다.

다음으로 StoreSelectorNode의 값을 사용하도록 도와주는 useStoreSelectorNode입니다.

const isServer = typeof window === "undefined"
 
const useStoreSelectorNode = <T>(selectorNode: SelectorNode<T>) => {
  const storeRef = React.useContext(StoreContext) // 전역 상태가 담기는 객체 컨텍스트
  const forceUpdate = useForceUpdate()
 
  React.useLayoutEffect(() => {
    const store = storeRef.current
 
    const storeSelectorNode = store.selectors.get(key)
 
    if (!storeSelectorNode) {
      return
    }
 
    const unsubscribe = storeSelectorNode.subscribe(forceUpdate)
 
    return unsubscribe
  }, [forceUpdate, selectorNode, storeRef])
 
  return storeRef.current.selectors.get(selectorNode.key)?.value // 리턴 타입은 T | undefined
}
const isServer = typeof window === "undefined"
 
const useStoreSelectorNode = <T>(selectorNode: SelectorNode<T>) => {
  const storeRef = React.useContext(StoreContext) // 전역 상태가 담기는 객체 컨텍스트
  const forceUpdate = useForceUpdate()
 
  React.useLayoutEffect(() => {
    const store = storeRef.current
 
    const storeSelectorNode = store.selectors.get(key)
 
    if (!storeSelectorNode) {
      return
    }
 
    const unsubscribe = storeSelectorNode.subscribe(forceUpdate)
 
    return unsubscribe
  }, [forceUpdate, selectorNode, storeRef])
 
  return storeRef.current.selectors.get(selectorNode.key)?.value // 리턴 타입은 T | undefined
}

여기까지는 useStoreNode와 크게 다른 부분은 없습니다. useStoreNode때와 마찬가지로, forceUpdate가 실행되면 리턴문이 다시 평가되기 때문에 최신 상태를 가져올 수 있습니다.

다만 이 코드만으로는 아직 구현이 안된 부분이 있습니다. 전역 상태에 selectorNode를 등록하는 로직이 필요합니다.

const registerSelectorNode = async <T>(store: Store, selectorNode: SelectorNode<T>) => {
  if (store.selectors.has(selectorNode.key)) {
    // 이미 등록된 selector라면 무시합니다.
    return
  }
 
  const dependencies = new Set<StoreNodeKey>()
  store.selectors.set(selectorNode.key, selectorNode)
 
  const initialValue = selectorNode.selector({
    get: (node) => {
      // 의존하는 전역 상태를 dependencies에 담습니다.
      dependencies.add(node.key)
 
      return store.state.get(node.key)
    },
  })
 
  store._selectors.set(selectorNode.key, {
    ...selectorNode,
    _dependencies: dependencies,
    subscribers: new Set(),
    value: initialValue,
  })
 
  return
}
const registerSelectorNode = async <T>(store: Store, selectorNode: SelectorNode<T>) => {
  if (store.selectors.has(selectorNode.key)) {
    // 이미 등록된 selector라면 무시합니다.
    return
  }
 
  const dependencies = new Set<StoreNodeKey>()
  store.selectors.set(selectorNode.key, selectorNode)
 
  const initialValue = selectorNode.selector({
    get: (node) => {
      // 의존하는 전역 상태를 dependencies에 담습니다.
      dependencies.add(node.key)
 
      return store.state.get(node.key)
    },
  })
 
  store._selectors.set(selectorNode.key, {
    ...selectorNode,
    _dependencies: dependencies,
    subscribers: new Set(),
    value: initialValue,
  })
 
  return
}

selector의 초기값을 평가하고, 의존하고 있는 전역 상태의 Setdependencies를 만들어 Store 객체에 등록합니다. 이렇게 정의된 registerSelectorNodeuseStoreSelectorNode에서 호출하면 됩니다.

const useStoreSelectorNode = <T>(selectorNode: SelectorNode<T>) => {
  const storeRef = React.useContext(StoreContext)
  const forceUpdate = useForceUpdate()
 
  React.useLayoutEffect(() => {
    const store = storeRef.current
 
    const storeSelectorNode = store.selectors.get(key)
 
    if (!storeSelectorNode) {
      registerSelectorNode(store, selectorNode)
    }
 
    const unsubscribe = storeSelectorNode.subscribe(forceUpdate)
 
    return unsubscribe
  }, [forceUpdate, selectorNode, storeRef])
 
  return storeRef.current.selectors.get(selectorNode.key)?.value
}
const useStoreSelectorNode = <T>(selectorNode: SelectorNode<T>) => {
  const storeRef = React.useContext(StoreContext)
  const forceUpdate = useForceUpdate()
 
  React.useLayoutEffect(() => {
    const store = storeRef.current
 
    const storeSelectorNode = store.selectors.get(key)
 
    if (!storeSelectorNode) {
      registerSelectorNode(store, selectorNode)
    }
 
    const unsubscribe = storeSelectorNode.subscribe(forceUpdate)
 
    return unsubscribe
  }, [forceUpdate, selectorNode, storeRef])
 
  return storeRef.current.selectors.get(selectorNode.key)?.value
}
selector 구현

이제 selector를 트리거하고, selector에 넘겨줄 get 함수의 구현이 필요합니다.

selector 함수는 전역 상태가 바뀔 때 실행되면 되므로, useStoreNodeSetter에서 전역 상태를 바꾸는 곳이 이 로직이 위치할 곳이 됩니다.

const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      existingNode.value = newValue
      existingNode.emitChange()
 
      // selector를 트리거합니다.
      store.selectors.forEach((selectorNode) => {
        if (!selectorNode._dependencies.has(storeNode.key)) {
          return
        }
 
        // selector가 의존하는 전역 상태가 바뀌었으므로
        // selector를 다시 실행합니다.
 
        const newValue = selectorNode.selector({
          get: (node) => {
            // getter 구현체
            return store.state.get(node.key)
          },
        })
 
        selectorNode.value = newValue
        selectorNode.emitChange() // selector의 구독자들에게 알립니다.
      })
    },
    [storeNode, storeRef],
  )
}
const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      existingNode.value = newValue
      existingNode.emitChange()
 
      // selector를 트리거합니다.
      store.selectors.forEach((selectorNode) => {
        if (!selectorNode._dependencies.has(storeNode.key)) {
          return
        }
 
        // selector가 의존하는 전역 상태가 바뀌었으므로
        // selector를 다시 실행합니다.
 
        const newValue = selectorNode.selector({
          get: (node) => {
            // getter 구현체
            return store.state.get(node.key)
          },
        })
 
        selectorNode.value = newValue
        selectorNode.emitChange() // selector의 구독자들에게 알립니다.
      })
    },
    [storeNode, storeRef],
  )
}
async selector 구현

'상태 관리'에서 빼놓을 수 없는 기능 중 하나는 비동기 처리입니다. 앞서 구현한 selector가 비동기 함수를 지원하도록 개선해보겠습니다.

먼저 Selector 타입이 Promise도 리턴할 수 있도록 변경합니다.

export type Selector<T> = ({ get }: { get: <U>(node: StoreNode<U>) => U }) => T | Promise<T>
export type Selector<T> = ({ get }: { get: <U>(node: StoreNode<U>) => U }) => T | Promise<T>

이제 register시 비동기 처리를 추가합니다.

const registerSelectorNode = async <T>(store: Store, selectorNode: SelectorNode<T>) => {
  if (store.selectors.has(selectorNode.key)) {
    return
  }
 
  const dependencies = new Set<StoreNodeKey>()
  store.selectors.set(selectorNode.key, selectorNode)
 
  const initialValue = selectorNode.selector({
    get: (node) => {
      dependencies.add(node.key)
 
      return store.state.get(node.key)
    },
  })
 
  if (initialValue instanceof Promise) {
    // 비동기 selector라면
    // 초기값을 기다립니다.
    selectorNode.value = await initialValue
  } else {
    selectorNode.value = initialValue
  }
 
  store._selectors.set(selectorNode.key, {
    ...selectorNode,
    _dependencies: dependencies,
    subscribers: new Set(),
  })
 
  return
}
const registerSelectorNode = async <T>(store: Store, selectorNode: SelectorNode<T>) => {
  if (store.selectors.has(selectorNode.key)) {
    return
  }
 
  const dependencies = new Set<StoreNodeKey>()
  store.selectors.set(selectorNode.key, selectorNode)
 
  const initialValue = selectorNode.selector({
    get: (node) => {
      dependencies.add(node.key)
 
      return store.state.get(node.key)
    },
  })
 
  if (initialValue instanceof Promise) {
    // 비동기 selector라면
    // 초기값을 기다립니다.
    selectorNode.value = await initialValue
  } else {
    selectorNode.value = initialValue
  }
 
  store._selectors.set(selectorNode.key, {
    ...selectorNode,
    _dependencies: dependencies,
    subscribers: new Set(),
  })
 
  return
}

selector를 트리거할 때도 비동기 처리를 고려해야 합니다.

const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      existingNode.value = newValue
      existingNode.emitChange()
 
      store.selectors.forEach((selectorNode) => {
        if (!selectorNode._dependencies.has(storeNode.key)) {
          return
        }
 
        const newValue = selectorNode.selector({
          get: (node) => {
            return store.state.get(node.key)
          },
        })
 
        if (newValue instanceof Promise) {
          // 비동기 selector라면
          // 값이 로드되기를 기다립니다.
          newValue.then((resolvedValue) => {
            selectorNode.value = resolvedValue
            selectorNode.emitChange()
          })
 
          return
        }
 
        selectorNode.value = newValue
        selectorNode.emitChange()
      })
    },
    [storeNode, storeRef],
  )
}
const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      existingNode.value = newValue
      existingNode.emitChange()
 
      store.selectors.forEach((selectorNode) => {
        if (!selectorNode._dependencies.has(storeNode.key)) {
          return
        }
 
        const newValue = selectorNode.selector({
          get: (node) => {
            return store.state.get(node.key)
          },
        })
 
        if (newValue instanceof Promise) {
          // 비동기 selector라면
          // 값이 로드되기를 기다립니다.
          newValue.then((resolvedValue) => {
            selectorNode.value = resolvedValue
            selectorNode.emitChange()
          })
 
          return
        }
 
        selectorNode.value = newValue
        selectorNode.emitChange()
      })
    },
    [storeNode, storeRef],
  )
}
상태가 바뀌지 않았다면 리렌더링을 하지 않도록 최적화

전역 상태가 바뀌지 않았다면 구독중인 컴포넌트를 리렌더링 할 필요 없습니다. 이를 고려하면 무거운 selector의 불필요한 실행도 방지할 수 있습니다.

const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      if (existingNode.value === newValue) {
        // 값이 바뀌지 않았다면
        // 아무것도 하지 않습니다.
        // 덕분에 이 StoreNode를 구독중인 컴포넌트의 불필요한 리렌더링이 방지되며
        // selector의 불필요한 실행도 방지됩니다.
        return
      }
 
      existingNode.value = newValue
      existingNode.emitChange()
 
      store.selectors.forEach((selectorNode) => {
        if (!selectorNode._dependencies.has(storeNode.key)) {
          return
        }
 
        const newValue = selectorNode.selector({
          get: (node) => {
            return store.state.get(node.key)
          },
        })
 
        if (newValue instanceof Promise) {
          newValue.then((resolvedValue) => {
            if (selectorNode.value === resolvedValue) {
              // 마찬가지로 값이 바뀌지 않았다면
              // 아무것도 하지 않습니다.
              return
            }
 
            selectorNode.value = resolvedValue
            selectorNode.emitChange()
          })
 
          return
        }
 
        if (selectorNode.value === newValue) {
          return
        }
 
        selectorNode.value = newValue
        selectorNode.emitChange()
      })
    },
    [storeNode, storeRef],
  )
}
const useStoreNodeSetter = <T>(storeNode: StoreNode<T>): ((newValue: T) => void) => {
  const storeRef = React.useContext(StoreContext)
 
  return React.useCallback(
    (newValue: T) => {
      const store = storeRef.current
 
      if (!store.state.has(storeNode.key)) {
        store.state.set(storeNode.key, storeNode)
      }
 
      const existingNode = store.state.get(storeNode.key) as StoreNode<T>
 
      if (existingNode.value === newValue) {
        // 값이 바뀌지 않았다면
        // 아무것도 하지 않습니다.
        // 덕분에 이 StoreNode를 구독중인 컴포넌트의 불필요한 리렌더링이 방지되며
        // selector의 불필요한 실행도 방지됩니다.
        return
      }
 
      existingNode.value = newValue
      existingNode.emitChange()
 
      store.selectors.forEach((selectorNode) => {
        if (!selectorNode._dependencies.has(storeNode.key)) {
          return
        }
 
        const newValue = selectorNode.selector({
          get: (node) => {
            return store.state.get(node.key)
          },
        })
 
        if (newValue instanceof Promise) {
          newValue.then((resolvedValue) => {
            if (selectorNode.value === resolvedValue) {
              // 마찬가지로 값이 바뀌지 않았다면
              // 아무것도 하지 않습니다.
              return
            }
 
            selectorNode.value = resolvedValue
            selectorNode.emitChange()
          })
 
          return
        }
 
        if (selectorNode.value === newValue) {
          return
        }
 
        selectorNode.value = newValue
        selectorNode.emitChange()
      })
    },
    [storeNode, storeRef],
  )
}

리액트 내부 구현중에도 비슷한 로직이 있는데, 거기에서 차용한 아이디어입니다.

구현된 추가 기능

이상으로 핵심 로직 설명을 마칩니다. very-simple-store에서 제공하는 기능은 이 외에도 조금 더 있습니다.

  • useCurrentStoreState: 현재 store 객체 전체를 확인할 수 있는 Hook입니다. 이를 위해 store 자체에서도 subscribe 기능을 제공하도록 했습니다.
  • 일부 코드의 고도화: Store와 관련된 로직은 모두 Store 객체 내부에 캡슐화 하고, Store 객체를 사용하는 컴포넌트들은 Store 객체의 메서드를 호출하는 방식으로 구현했습니다. 이렇게 하면 Store 객체의 내부 구현이 바뀌어도 사용자에게는 영향을 주지 않습니다.

실제 코드를 보고 싶으시다면 여기를 참고해주세요.

마치며

Recoil의 API와 내부 구현에서 영감을 받아 직접 간단한 형태의 전역 상태 라이브러리를 구현해봤습니다. 여러 로직을 고민해보며 실제 코드가 왜 그렇게 구현됐을지 이해하는 과정이 재밌었습니다. selector가 StoreNode 뿐만 아니라 StoreSelectorNode도 가져올 수 있게 하고, React.useSyncExternalStore를 사용할 수 있도록 개선해 서버 사이드 동작에도 완벽하게 대응할 수 있도록 하는 등의 추가 기능도 구현해보고 싶습니다.