본문 바로가기
FE/모던 리액트 Deep Dive

5. 리액트와 상태관리 라이브러리 - 2

by Toddler_AD 2025. 10. 6.

5.2 리액트 훅으로 시작하는 상태 관리

  • 비교적 오랜 기간 리액트 생태계에서는 리액트 애플리케이션의 상태 관리를 위해 리덕스에 의존했다. 과거 리액트 코드를 보면 리액트와 리덕스가 함께 설치돼 있는 것을 흔히 볼 수 있었고, 일부 개발자들은 리액트와 리덕스를 마치 하나의 프레임워크 내지는 업계 표준(de facto)으로 여기기도 했다. 그러나 현재는 새로운 Context API, useReducer, useState의 등장으로 컴포넌트에 걸쳐서 재사용하거나 혹은 컴포넌트 내부에 걸쳐서 상태를 관리할 수 있는 방법들이 점차 등장하기 시작했고, 덕분에 리덕스 외의 다른 상태 관리 라이브러리를 선택하는 경우도 많아지고 있다. 리액트 16.8에서 등장한 훅과 함수 컴포넌트의 패러다임에서 애플리케이션 내부 상태 관리는 어떻게 할 수 있고, 이러한 새로운 방법을 채택한 라이브러리는 무엇이 있고 어떻게 작동하는 지 알아보자.

5.2.1 가장 기본적인 방법 : useState와 useReducer

  • useState의 등장으로 리액트에서는 여러 컴포넌트에 걸쳐 손쉽게 동일한 인터페이스의 상태를 생성하고 관리할 수 있게 됐다. 다음 예제 훅을 살펴보자
function useCounter(initCount: number = 0) {
  const [counter, setCounter] = useState(initCount);

  function inc() {
    setCounter((prev) => prev + 1);
  }
  return { counter, inc };
}
  • 이 에제는 useCounter라는 훅을 만들어서 함수 컴포넌트 어디에서든 사용할 수 있게 구현한 사례다. 이 훅은 외부에서 받은 숫자 혹은 0을 초기값으로 상태를 관리하며, inc라는 함수를 선언해 이 숫자를 1씩 증가시킬 수 있게 구현했다. 그리고 상태값인 counter와 inc 함수를 객체로 반환한다.
  • 다음 코드와 같이 useCounter를 사용하는 함수 컴포넌트는 이 훅을 사용해 각자의 counter 변수를 관리하며, 중복되는 로직 없이 숫자를 1씩 증가시키는 기능을 손쉽게 이용할 수 있다.
function useCounter(initCount: number = 0) {
  const [counter, setCounter] = usestate(initCount);

  function inc() {
    setCounter((prev) => prev + 1);
  }

  return { counter, inc };
}

function Counter1() {
  const { counter, inc } = useCounter();

  return (
    <>
      <h3>Counter1: {counter}</h3>
      <button onclick={inc}>+</button>
    </>
  );
}

function Counter2() {
  const { counter, inc } = useCounter();
  return (
    <>
      <h3>Counter2: {counter} </h3>
      <button onclick={inc}>+</button>
    </>
  );
}
  • useCounter라는 훅이 없었다면 이러한 기능이 각각의 컴포넌트에서 모두 위와 같은 내용을 구현해야만 했을 것이다. 더 나아가 훅 내부에서 관리해야 하는 상태가 복잡하거나 상태를 변경할 수 있는 시나리오가 다양해진다면 훅으로 코드를 격리해 제공할 수 있다는 장점이 더욱 크게 드러날 것이다. 이처럼 리액트의 훅을 기반으로 만든 사용자 정의 훅은 함수 컴포넌트라면 어디서든 손쉽게 재사용 가능하다는 장점이 있다. 
  • useState와 비슷한 훅인 useReducer 또한 마찬가지로 지역 상태를 관리할 수 있는 훅이다. 앞서 2장에서 useReducer를 다루면서 잠깐 다뤘던 내용 중 하나는, 실제로 useState는 useReducer로 구현됐다는 사실이다. 이러한 사실을 증명하기 위해 react와 preact의 소스코드를 발췌했었는데, 이를 실제 코드로 작성하면 다음과 비슷한 코드로 예상해볼 수 있다.
[useState를 useReducer로 구현하는 예제]
type initializer<T>= T extends any ? T ((prev: T) => T) : never

function useStateWithUseReducer<T>(initialstate: T) {


const [state, dispatch] = useReducer(
  (prev: T, action: Initializer<T>) =>
    typeof action === 'function' ? action(prev) : action,
    initialState,
  )
  return [state, dispatch]
}
  • 먼저 useState를 useReducer로 구현하는 예제다. useReducer의 첫 번째 인수로는 reducer, 즉 state와 action을 어떻게 정의할지를 넘겨줘야 하는데 useState와 동일한 작동, 즉 T를 받거나 (prev: T) => T를 받아 새로운 값을 설정할 수 있게끔 코드를 작성했다.
  • 이와 반대로, useReducer 또한 useState로 작성할 수 있다.
[useReducer를 usestate로 구현하는 예제]
function useReducerwithuseState(reducer, initialstate, initializer) {
  const [state, setstate] = usestate(
    nitializer ? () => initializer(initialstate) : initialstate,
  )

  const dispatch = useCallback(
    (action) => setstate((prev) => reducer(prev, action)),
    [reducer ],
  )
  return [state, distatch]
}
  • useReducer를 타입스크립트로 작성하려면 다양한 형태의 오버로딩이 필요한데 코드의 대략적인 구성만 간단하게 설명하기 위해서 자바스크립트로 작성했다.
  • useState나 useReducer 모두 약간의 구현상의 차이만 있을 뿐, 두 훅 모두 지역 상태 관리를 위해 만들어졌다는 것을 알 수 있다.
  • 지금까지 일반적으로 사용되는 useState나 useReducer로 컴포넌트 내부의 상태를 관리하는 방법에 대해 알아봤다. 그러나 실제 애플리케이션을 작성해 보면 알겠지만 useState와 useReducer가 상태 관리의모든 필요성과 문제를 해결해 주지는 않는다. useState와 useReducer를 기반으로 하는 사용자 지정 훅의 한계는 명확하다. 훅을 사용할 때마다 컴포넌트별로 초기화되므로 컴포넌트에 따라 서로 다른 상태를 가질 수 밖에 없다. 위 예제의 경우 counter는 useCounter가 선언될 때마다 새롭게 초기화되어, 결론적으로 컴포넌트별로 상태의 파편화를 만들어버린다. 이렇게 기본적인 useState를 기반으로 한 상태를 지역 상태(local state)하며, 이 지역 상태는 해당 컴포넌트 내에서만 유효하다는 한계가 있다. 
  • 만약 useCounter에서 제공하는 counter를 올리는 함수는 지금처럼 동일하게 사용하되, 두 컴포넌트가 동일한 counter 상태를 바라보게 하기 위해서는 어떻게 해야 할까? 즉, 현재 지역 상태인 counter를 여러 컴포넌트가 동시에 사용할 수 있는 전역 상태(global state)로 만들어 컴포넌트가 사용하는 모든 훅이 동일한 값을 참조할 수 있게 하려면 어떻게 해야 할까? 가장 먼저 떠오르는 방법은 상태를 컴포넌트 밖으로 한 단계 끌어 올리는 것이다. 다음 예제를 보자.
function Counter1({ counter, inc }: { counter: number, inc: () => void }) {
  return (
    <>
      <h3>Counter1: {counter} </h3>
      <button onclick={inc}>+</button>
    </>
  );
}
function Counter2({ counter, inc }: { counter: number, inc: () => void }) {
  return (
    <>
      <h3>Counter2: {counter}</h3>
      <button onClick={inc}>+</button>
    </>
  );
}

function Parent() {
  const { counter, inc } = useCounter();

  return (
    <>
      <counter1 counter={counter} inc={inc} />
      <Counter2 counter={counter} inc={inc} />
    </>
  );
}
  • 예제에서는 useCounter를 각 컴포넌트에서 사용하는 대신, Parent라고 불리는 상위 컴포넌트 에서만 useCounter를 사용하고, 이 훅의 반환값을 하위 컴포넌트의 props로 제공했다. 즉, 지역 상태인 useCounter를 부모 컴포넌트로 한 단계 끌어올린 다음, 이 값을 하위 컴포넌트에서 참조해 재사용하게끔 만들었다. 이제 적어도 Parent 내부에서는 위의 props 규칙만 잘 지킨다면 하나의 counter 값과 하나의 inc 함수로 상태를 관리할 수 있게 된다. 즉, 컴포넌트 내부의 지역 상태를 전역으로 사용하기 위해 이 상태가 필요한 모든 컴포넌트인 Counter1과 Counter2를 Parent 내부로 이동시켜서 두 컴포넌트가 동일하게 사용할 수 있도록 만들었다. 여러 컴포넌트가 동일한 상태를 사용할 수 있게 됐다는 점은 주목할 만 하지만 props 형태로 필요한 컴포넌트에 제공해야 한다는 점은 여전히 조금은 불편해 보인다. 이후에 이러한 점을 어떻게 개선할 수 있을지 살펴보자.
  • 지금까지 useState와 useReducer, 그리고 사용자 지정 훅을 활용한 지역 상태 관리를 살펴봤다. 이 두 훅은 만들이게 따라 재사용할 수 있는 지역 상태를 만들어 주지만 이는 지역 상태라는 한계 때문에 여러 컴포넌트에 걸쳐 공유하기 위해서는 컴포넌트 트리를 재설계하는 등의 수고로움이 필요하다.

5.2.2 지역 상태의 한계를 벗어나보자 : useState의 상태를 바깥으로 분리하기

  • 리액트 코드의 작동 여부를 떠나서 조금 더 과감하게 상상해 보자. useState의 한계는 명확하다. 현재 리액트의 useState는 리액트가 만든 클로저 내부에서 관리되어 지역 상태로 생성되기 때문에 해당 컴포넌트에서만 사용할 수 있다는 단점이 있다. 만약 useState가 이 리액트 클로저가 아닌 다른 자바스크립트 실행 문맥 어디에선가, 즉 완전히 다른 곳에서 초기화돼서 관리되면 어떨까? 그리고 그 상태를 참조하는 유효한 스코프 내부에서는 해당 객체의 값을 공유해서 사용할 수도 있지 않을까? 즉, 어딘가에서 해당 값을 업데이트하면 그 값을 참조하고 있는 컴포넌트나 훅에서도 그 업데이트된 값을 사용할 수도 있지 않을까? 이를테면 다음과 같이 관리하는 상상을 해보자. 먼저 counter.ts라는 별개의 파일을 생성해서 다음과 같이 코드를 작성해 보자.
// counter.ts
export type State = { counter: number }
 
// 상태를 아예 컴포넌트 밖에 선언했다. 각 컴포넌트가 이 상태를 바라보게 할 것이다. 
let State = {
    ounter : 0,
}

// getter
export function get(): State{
    return State
}

// useState와 동일하게 구현하기 위해 게으른 초기화 함수나 값을 받을 수 있게 했다.
type Initialize<T>  = T extends any ? T | ((prev: T) => T) : never

// setter
export function set<T>(nextState: Initializer<T>){
    state = typeof nextState === 'function' ? nextState(State) : nextState
}

// Counter
function Counter(){
    const state = get()

    function handleClick() {
        set((prev: State) => ({ counter: prev.counter + 1 }))
    }

    return (
        <>
            <h3>{state.counter}</h3>
            <button onClick={handleClick}+</button>
        </>
    )
}
  • 언뜻 보면 이러한 방식은 작동할 것 같지만 아쉽게도 위 방식은 리액트 환경에서 작동하지 않는다. setter나 getter 등의 코드가 잘못돼서가 아니다. 브라우저 개발자 모드나 디버거, console.log 등으로 살펴보면 set을 통해 컴포넌트 외부에 있는 state도 잘 업데이트되고 있고, get()으로 변수의 최신값을 가져오는 것도 정상적으로 작동하고 있다. 그러나 가장 큰 문제는 컴포넌트가 리렌더링되지 않는다는 것이다. 원인은 바로 리액트의 랜더링 방식 때문이다. 새로운 상태를 사용자의 UI에 보여주기 위해서는 반드시 리렌더링이 필요하다 이 리렌더링은 함수 컴포넌트의 재실행(호출), useState의 두 번재 인수 호출 등 다양한 방식으로 일어나지만 위 코드에서는 리렌더링을 일으키는 장치가 어디에도 존재하지 않는다.
  • 즉, 업데이트되는 값을 가져오려면 상태를 업데이트하는 것뿐만 아니라 상태가 업데이트됐을때 이를 컴포넌트에 반영시키기 위한 리렌더링이 필요하며, 2.4절 '렌더링은 어떻게 일어나는가?'에서 살펴본 것처럼 함수 컴포넌트에서 리렌더링을 하려면 다음과 같은 작업 중 하나가 일어나야 한다.
    • useState, useReducer의 반환값 중 두 번째 인수가 어떻게든 호출한다. 설령 그것이 컴포넌트 렌더링과 관계없는 직접적인 상태를 관리하지 않아도 상관없다. 어떠한 방식으로든 두 번째 인수가 호출되면 리액트는 다시 컴포넌트를 렌더링 한다.
    • 부모 함수(부모 컴포넌트)가 리렌더링되거나 해당 함수(함수 컴포넌트)가 다시 실행돼야 한다. 그러나 위 경우 부모 컴포넌트가 없으며, props도 없기 때문에 일일이 Counter()를 재실행시켜야 하지만 그것은 매우 비효율적이다.
  • 여기서 우리가 시도해 볼 수 있는 것은 useState와  useReducer뿐으로 보인다. 그렇다면 useState의 인수로 컴포넌트 밖에서 선언한 state를 넘겨주는 방식으로 코드를 변경해 보자.
function Counter1() {
  const [count, setCount] = useState(state);

  function handleClick() {
    //외부에서 선언한 set 함수 내부에서 다음 상태값을 연산하고 그 값을 로컬 상태값에 넣음
    set((prev) => {
      const newState = { counter: prev.counter + 1 };
      //setCount 호출로 컴포넌트 리렌더링
      setCount(newState);
      return newState;
    });
  }

  return (
    <>
      <h3>{count.counter}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
}

function Counter2() {
  const [count, setCount] = useState(state);

  //위 컴포넌트와 동일 작동
  function handleClick() {
    set((prev) => {
      const newState = { counter: prev.counter + 1 };
      setCount(newState);
      return newState;
    });
  }

  return (
    <>
      <h3>{count.counter}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
}
  • 예제 코드에서는 억지로 전역에 있는 상태를 참조하게 만들었다. useState의 초기값으로 컴포넌트 외부에 있는 값을 사용하는 위와 같은 방식은 일반적인 리액트 코드 작성 방식과 동일하다. 여기서 독특한 것은 바로 handleClick으로 state를 업데이트 하는 방식이다. 기본적으로 useState의 두 번째 인수로 업데이트 하는 것은 해당 지역 상태에만 영향을 미치기 때문에 여기서는 외부에 선언한 set을 실행해 외부의 상태값 또한 업데이트하도록 수정했다. 이렇게 외부의 상태를 수정하고 useState의 두 번째 인수도 실행한다면 리액트 컴포넌트는 렌더링 될 것이고 우리는 계속해서 외부의 값을 안정적으로 참조할 수 있게 된다.
  • 그러나 이 방법은 굉장히 비효율적이고 문제점도 가지고 있다. 외부에 상태가 있음에도 불구하고, 함수 컴포넌트의 렌더링을 위해 함수의 내부에 돌일한 상태를 관리하는 useState가 존재하는 구조다. 이는 상태를 중복해서 관리하므로 비효율적인 방식이라고 볼 수 있다. 여기에는 또 한가지 문제점이 있는데, 실제로 각 컴포넌트의 버튼을 누르면 이상하게 작동하는 것을 확인할 수 있다. 버튼을 누르면 해당 컴포넌트가 렌더링되면서 원하는 값을 안정적으로 렌더링하지만 같은 상태를 바라봐야 하는 반대쪽 컴포넌트에서는 렌더링되지 않는다. 반대쪽 컴포넌트는 버튼을 눌러야 그제서야 렌더링되어 최신값을 불러온다. 그러나 여전히 반대쪽은 렌더링되지 않는 것을 볼 수 있다. 왜 같은 상태를 공유하지만 동시에 렌더링되지 않는 것일까?
  • useState로 컴포넌트의 리렌더링을 실행해 최신값을 가져오는 방법은 어디까지나 해당 컴포넌트 자체에서만 유효한 전략이다. 즉, 반대쪽의 다른 컴포넌트에서는 여전히 상태의 변화에 따른 리렌더링을 일으킬 무언가가 없기 때문에 클릭 이벤트가 발생하지 않는 다른 쪽은 여전히 렌더링이 되지 않는다.
  • 이러한 한계를 종합해 본 내용을 살펴보면 함수 외부에서 상태를 참조하고 이를 통해 렌더링까지 자연스럽게 일어나려면 다음과 같은 조건을 만족해야 한다는 결론에 도달한다. 
    • 꼭 window나 global에 있어야 할 필요는 없지만 컴포넌트 외부 어딘가에 상태를 두고 여러 컴포넌트가 같이 쓸 수 있어야 한다. 
    • 이 외부에 있는 상태를 사용하는 컴포넌트는 상태의 변화를 알아챌 수 있어야 하고 상태가 변화될 때마다 리렌더링이 일어나서 컴포넌트를 최신 상태값 기준으로 렌더링해야 한다. 이 상태 감지는 상태를 변경시키는 컴포넌트뿐만 아니라 이 상태를 참조하는 모든 컴포넌트에서 동일하게 작동해야 한다.
    • 상태가 원시값이 아닌 객체인 경우에 그 객체에 내가 감지하지 않는 값이 변한다 하더라도 리렌더링이 발생해서는 안 된다. 예를 들어, {a:1, b: 2}라는 상태가 있으며 어느 컴포넌트에서 a를 2로 업데이트했다고 가정해 보자. 이러한 객체 값의 변화가 단순히 b의 값을 참조하는 컴포넌트에서는 리렌더링을 일으켜서는 안 된다는 뜻이다.
  • 위와 같은 조건을 만족할 수 있는, 컴포넌트 레벨의 지역 상태를 벗어나는 새로운 상태 관리 코드를 만들어보자. 먼저 이 상태는 객체일 수도, 원시값일 수도 있으므로 좀 더 범용적인 store로 정의한다. 그리고 2번의 조건을 만족하기 위해서는 store의 값이 변경될 때마다 변경됐음을 알리는 callback 함수를 실행해야 하고, 이 callback을 등록할 수 있는 subscribe 함수가 필요하다.
  • 먼저 위 조건을 만족하는 store의 뼈대를 만들어보자. 타입스크립트의 타입을 선언해 두면 직접 코드를 작성하기에 앞서 만들어야 할 함수의 기본적인 타입을 정의해두고 이야기할 수 있으므로 유용하다.
type Initialize<T>  = T extends any ? T | ((prev: T) => T) : never

type Store<State> = {
    get: () => State
    set: (action: Initialize<State>) => State
    subscribe: (callback: () => void) => () => void
}
  • get은 항상 최신값을 가져와야 하므로 함수로 구현했다. 이 get을 변수 대신 함수로 만들어두면 항상 새롭게 값을 가져오기 위해 시도할 것이므로 최신값을 가져올 수 있다. 그리고 set의 형태는 기존에 리액트 개발자가 널리 사용하고 있는 useState와 동일하게 값 또는 함수를 받을 수 있도록 만들었다. 마지막으로 subscribe는 이 store의 변경을 감지하고 싶은 컴포넌트들이 자신의 callback 함수를 등록해 두는 곳이다. callback을 인수로 받으며, store는 값이 변경될 때마다 자신에게 등록된 모든 callback을 실행하게 할 것이다. 그리고 이 스토어를 참조하는 컴포넌트는 subscribe에 컴포넌트 자기 자신을 렌더링하는 코드를 추가해서 컴포넌트가 리렌더링을 실행할 수 있게 만들 것이다.
  • 뼈대를 만들었으니 이 store<State> 함수를 실제로 작성해 보자.
export const createStore = (initialState) => {
  let state =
    typeof initialState !== 'function' ? initialState : initialState();

  //유일 저장할 수 있는 Set
  const callbacks = new Set();

  const get = () => state;
  const set = (nextState) => {
    //인수가 함수라면 함수를 실행해 새로운 값을 받고
    //아니면 새로운 값 그대로 사용
    state = typeof nextState === 'function' ? nextState(state) : nextState;

    //값의 설정이 발생하면 콜백 목록 순회하며 모든 콜백 실행
    callbacks.forEach((callback) => callback());
    return state;
  };

  //콜백을 인수로 받음
  const subscribe = (callback) => {
    //받은 인수를 콜백 목록에 추가
    callbacks.add(callback);

    return () => {
      callbacks.delete(callback);
    };
  };

  return { get, set, subscribe };
};
  • 위 코드는 store를 만드는 createStore를 구현한 코드다. 하나씩 단계로 살펴보자.
    1. 먼저 store의 초기값을 state 또는 게으른 초기화 함수를 받아 store의 기본값을 초기화할 수 있게 해준다.
    2. 1번에서 받은 인수를 바탕으로 함수를 실행하거나 초기값 그 자체를 할당해 state 초기값을 할당한다.
    3. 컴포넌트로 넘겨받는 콜백 함수를 저장하기 위해 callbacks를 Set으로 선언한다. Set은 원시값이나 개게에 관계없이 유일한 값을 저장할 수 있어 중복 없이 콜백 함수를 저장하는 용도로 유명하다.
    4. get을 함수로 만들어 매번 최신값을 가져올 수 있게 만든다.
    5. set을 만들어 새로운 값을 넣을 수 있도록 만든다. useState의 두 번째 인수와 마찬가지로 함수일 수도, 단순히 값을 받을 수도 있다. 그리고 값을 설정한 이후에 callbacks를 순회해 등록된 모든 콜백을 실행한다. set으로 값을 설정하는 순간 콜백을 모두 실행해 컴포넌트의 렌더링을 유도할 것이다.
    6. subscribe는 callbacks Set에 callback을 등록할 수 있는 함수다. callback.add와 더불어, 반환값으로는 등록된 callback을 삭제하는 함수를 반환한다. 이는 callbacks에 callback이 무한히 추가되는 것을 방지하게 만들어져 있으며, useEffect의 클린업 함수와 동일한 역할을 한다.
    7. 마지막으로 get, set, subscribe를 하나의 객체로 반환해 외부에서 사용할 수 있도록 한다.
  • 요약하자면 createStore는 자신이 관리해야 하는 상태를 내부 변수로 가진 다음, get 함수로 해당 변수의 최신값을 제공하며, set 함수로 내부 변수를 최신화하며, 이 과정에서 등록된 콜백을 모조리 실행하는 구조를 띠고 있다.
  • 마지막으로 createStore로 만들어진 store의 값을 참조하고, 그리고 이 값의 변화에 따라 컴포넌트 렌덩링을 유도할 사용자 정의 훅이 필요하다. useStore라는 훅을 만들어 이 store의 변화를 감지할 수 있게 코드를 작성해 보자.
export const useStore = (store) => {
  const [state, setState] = useState(() => store.get());

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.get());
    });
    //클린업 함수 실행
    //useEffect 훅에서 반환하는 함수는 컴포넌트가 언마운트되거나,
    //의존성 배열에 있는 값이 변경될 때 실행
    return unsubscribe;
  }, [store]);

  return [state, store.set];
};
  • 이 훅이 어떻게 구성돼 있는지 살펴보자.
    1. 먼저 훅의 인수로 사용할 store를 받는다.
    2. 이 스토어의 값을 초기값으로 하는 useState를 만든다. 이제 이 useState가 컴포넌트의 렌더링을 유도한다.
    3. useEffect는 store의 현재 값을 가져와 setState를 수행하는 함수를 store의 subscribe로 등록해 두었다. createStore 내부에서 값이 변경될 때마다 subscribe에 등록된 함수를 실행하므로 useStore 내부에서는 store의 값이 변경될 때마다 state의 값이 변경되는 것을 보장받을 수 있다.
    4. 마지막으로 useEffect의 클린업 함수로 unsubscribe를 등록해 둔다. useEffect의 작동이 끝난 이후에는 callback에서 해당 함수를 제거해 callback이 계속해서 쌓이는 현상을 방지했다,
  • 이제 우리가 원하는 상태 관리에 필요한 모든 코드를 작성했다. 이 상태 관리 방식이 잘 작동하는지 확인해 보자.
const store = createStore({ count: 0 });

function Counter1() {
  const [state, setState] = useStore(store);

  function handleClick() {
    setState((prev) => ({ count: prev.count + 1 }));
  }

  return (
    <>
      <h3>Counter1: {state.count}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
}

function Counter2() {
  const [state, setState] = useStore(store);

  function handleClick() {
    setState((prev) => ({ count: prev.count + 1 }));
  }

  return (
    <>
      <h3>Counter1: {state.count}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
}

export default function App() {
  return (
    <div className="App">
      <Counter1 />
      <Counter2 />
    </div>
  );
}
  • Counter1과 Counter2의 버튼을 각각 클릭하면 store의 상태가 변경됨과 동시에 두 컴포넌트가 정상적으로 리렌더링되는 것을 확인할 수 있다. 마침내 우리가 일반적으로 사용하는 상태 관리 라이브러리를 손수 구현했다.
  • 그러나 이 useStore도 완벽한 것은 아니다. 만드는 스토어의 구조가 원시값이라면 상관없지만 객체인 경우를 가정해 보자. 만약 해당 객체에서 일부값만 변경한다면 어떻게 될까? 현재는 store의 값이 바뀌면 무조건 useState를 실행하므로 스토어에 어떤 값이 바뀌든지 간에 리렌더링이 일어날 것이다. useStore에서 한 발짝 더 나아가서 원하는 값이 변했을 때만 리렌더링되도록 훅을 다시 구성해보자.
  • 여기서 수정이 필요한 것은 useStore 훅이다. subscribe는 위 예제와 동일하게 수행하되, 변경 감지가 필요한 값만 setState를 호출해 객체 상태에 대한 불필요한 리렌더링을 막을 수 있을 것이다. 다음 예제를 보자.
export const useStoreSelector = (store, selector) => {
  const [state, setState] = useState(() => selector(store.get()));

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      const value = selector(store.get());
      setState(value);
    });
    return unsubscribe;
  }, [store, selector]);

  return state;
};
  • useStoreSelector는 useStore를 기반으로 만들어졌지만 한 가지 차이점이 있다. 두 번째 인수로 selector라고 하는 함수를 받는 것이다. 이 함수는 store의 상태에서 어떤 값을 가져올지 정의하는 함수로, 이 함수를 활용해 store.get()을 수행한다. useState는 값이 변경되지 않으면 리렌더링을 수행하지 않으므로 store의 값이 변경됐다 하더라도 selector(store.get())이 변경되지 않는다면 리렌더링이 일어나지 않는다. 이 useStoreSelector 훅을 사용하는 예제를 살펴보자.
const store = createStore({ count: 0, text: 'hi' });

function Counter() {
  const counter = useStoreSelector(
    store,
    useCallback((state) => state.count, [])
  );

  function handleClick() {
    store.set((prev) => ({ ...prev, count: prev.count + 1 }));
  }

  useEffect(() => {
    console.log('Counter Rendered');
  });

  return (
    <>
      <h3>{counter}</h3>
      <button onClick={handleClick}>+</button>
    </>
  );
}

const textSelector = (state) => state.text;

function TextEditor() {
  const text = useStoreSelector(store, textSelector);

  useEffect(() => {
    console.log('Counter Rendered');
  });

  function handleChange(e) {
    store.set((prev) => ({ ...prev, text: e.target.value }));
  }

  return (
    <>
      <h3>{text}</h3>
      <input value={text} onChang={handleChange} />
    </>
  );
}
  • 이제 useStoreSelector를 사용하면 store가 객체로 구성되어 있다 하더라도 컴포넌트에서 필요한 값만 말 그대로 select 해서 사용할 수 있고, 이 select 또한 실제로 객체에서 변경된 값에 대해서만 수행하라 것이다.
  • 한 가지 주의할 점은 useStoreSelector에 제공하는 두 번재 인수인 selector를 컴포넌트 밖에 선언하거나, 이것이 불가능하다면 useCallback을 사용해 참조를 고정시켜야 한다는 것이다. 만약 컴포넌트 내에 이 selector 함수를 생성하고 useCallback으로 감싸두지 않는다면 컴포넌트가 리렌더링될 때마다 함수가 계속 재생성되어 store의 subscribe를 반복적으로 수행할 것이다.
  • 지금까지 리액트 외부에서 관리되는 값에 대한 변경을 추적하고, 이를 리렌더링까지 할 수 있는 useStoreSelector 훅을 만들어서 설치해 봤다. 사실 이러한 방식으로 구현된 훅은 이미 존재한다. 바로 페이스북 팀에서 만든 useSubscription이다. useSubscription을 사용하면 방금 했던 내용을 동일하게 구현할 수 있다.
function NewCounter() {
  const subscription = useMemo(
    () => ({
      getCurrentValue: () => store.get(),
      subscribe: (callback) => {
        const unsubscribe = store.subscribe(callback);
        return () => unsubscribe();
      },
    }),
    []
  );

  const value = useSubscription(subscription);

  return <>{JSON.stringify(value)}</>;
}
  • useStoreSelector나 useStore를 사용했던 것과 마찬가지로 useSubscription을 사용하면 외부에 있는 데이터를 가져와서 사용하고 리렌더링까지 정상적으로 수행되는 것을 확인할 수 있다.
  • 그렇다면 이 useSubscription은 앞서 만든 useStore와 어떤 차이가 있을까? 앞서 구현한 코드와 비슷한 점도 있지만 몇 가지 차이점을 확인해 볼 수 있다. 다음 코드는 useSubscription을 타입스크립트로 흉내 낸 코드다. 
/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */

import {useDebugValue, useEffect, useState} from 'react';

// Hook used for safely managing subscriptions in concurrent mode.
//
// In order to avoid removing and re-adding subscriptions each time this hook is called,
// the parameters passed to this hook should be memoized in some way–
// either by wrapping the entire params object with useMemo()
// or by wrapping the individual callbacks with useCallback().
export function useSubscription<Value>({
  // (Synchronously) returns the current value of our subscription.
  getCurrentValue,

  // This function is passed an event handler to attach to the subscription.
  // It should return an unsubscribe function that removes the handler.
  subscribe,
}: {|
  getCurrentValue: () => Value,
  subscribe: (callback: Function) => () => void,
|}): Value {
  // Read the current value from our subscription.
  // When this value changes, we'll schedule an update with React.
  // It's important to also store the hook params so that we can check for staleness.
  // (See the comment in checkForUpdates() below for more info.)
  const [state, setState] = useState(() => ({
    getCurrentValue,
    subscribe,
    value: getCurrentValue(),
  }));

  let valueToReturn = state.value;

  // If parameters have changed since our last render, schedule an update with its current value.
  if (
    state.getCurrentValue !== getCurrentValue ||
    state.subscribe !== subscribe
  ) {
    // If the subscription has been updated, we'll schedule another update with React.
    // React will process this update immediately, so the old subscription value won't be committed.
    // It is still nice to avoid returning a mismatched value though, so let's override the return value.
    valueToReturn = getCurrentValue();

    setState({
      getCurrentValue,
      subscribe,
      value: valueToReturn,
    });
  }

  // Display the current value for this hook in React DevTools.
  useDebugValue(valueToReturn);

  // It is important not to subscribe while rendering because this can lead to memory leaks.
  // (Learn more at reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects)
  // Instead, we wait until the commit phase to attach our handler.
  //
  // We intentionally use a passive effect (useEffect) rather than a synchronous one (useLayoutEffect)
  // so that we don't stretch the commit phase.
  // This also has an added benefit when multiple components are subscribed to the same source:
  // It allows each of the event handlers to safely schedule work without potentially removing an another handler.
  // (Learn more at https://codesandbox.io/s/k0yvr5970o)
  useEffect(() => {
    let didUnsubscribe = false;

    const checkForUpdates = () => {
      // It's possible that this callback will be invoked even after being unsubscribed,
      // if it's removed as a result of a subscription event/update.
      // In this case, React will log a DEV warning about an update from an unmounted component.
      // We can avoid triggering that warning with this check.
      if (didUnsubscribe) {
        return;
      }

      // We use a state updater function to avoid scheduling work for a stale source.
      // However it's important to eagerly read the currently value,
      // so that all scheduled work shares the same value (in the event of multiple subscriptions).
      // This avoids visual "tearing" when a mutation happens during a (concurrent) render.
      const value = getCurrentValue();

      setState(prevState => {
        // Ignore values from stale sources!
        // Since we subscribe an unsubscribe in a passive effect,
        // it's possible that this callback will be invoked for a stale (previous) subscription.
        // This check avoids scheduling an update for that stale subscription.
        if (
          prevState.getCurrentValue !== getCurrentValue ||
          prevState.subscribe !== subscribe
        ) {
          return prevState;
        }

        // Some subscriptions will auto-invoke the handler, even if the value hasn't changed.
        // If the value hasn't changed, no update is needed.
        // Return state as-is so React can bail out and avoid an unnecessary render.
        if (prevState.value === value) {
          return prevState;
        }

        return {...prevState, value};
      });
    };
    const unsubscribe = subscribe(checkForUpdates);

    // Because we're subscribing in a passive effect,
    // it's possible that an update has occurred between render and our effect handler.
    // Check for this and schedule an update if work has occurred.
    checkForUpdates();

    return () => {
      didUnsubscribe = true;
      unsubscribe();
    };
  }, [getCurrentValue, subscribe]);

  // Return the current value for our caller to use while rendering.
  return valueToReturn;
}
  • 리액트에서 구현한 useSubscription다. Flow로 작성된 코드지만 이해를 돕기 위해 임의로 타입스크립트로 변환했으며 약간의 차이가 있다.
  • 크게 눈에 띄는 차이점은 selector(여기서는 getCurrentValue)와 subscribe에 대한 비교도 추가했다는 것이다. 방금 만든 useStore나 useStoreSelector 모두 useEffect의 의존성 배열에 store나 selector가 들어가 있어 이 객체가 임의로 변경될 경우 불필요하게 리렌더링이 발생한다는 문제가 있다. 이를 방지하기 위해 useSubscription 내부에는 예외 처리를 추가해 이러한 변경이 알려지는 동안에는 store나 selector의 변경을 무시하고 한정적으로 원하는 값을 반환하게끔 훅이 작성돼 있다. 이는 우리가 앞서 작성한 예제 코드보다 훨씬 더 안정적으로 상태를 제공할 수 있게 하는 안전장치로 볼 수 있다. 
  • 리액트 18 버전의 useSubscription을 살펴보면 useSubscription 훅 자체가 useSyncExternalStore로 재작성돼 있는 것을 볼 수 있다.