본문 바로가기

호기심 천국

React) useState는 클로저로 동작할까?

참새 두 마리. Unsplash에 Lingchor 님이 올림

요약

ㅇㅇ

들어가는 말

우리가 useState를 쓸 때를 상상해보자.

 

const [myCount, setMyCount] = useState(0);
const [yourCount, setYourCount] = useState(0);

 

분명 우리는 각 상태에 어떠한 이름도 주지 않는다. myCount, yourCount는 우리가 쓰기 위한 변수명에 불과하지, 우리는 리액트에게 이 상태의 정체성에 대해 어떠한 언급도 하지 않는다. 단지 0이라는 초깃값만 전달할 뿐.

그리고 우리는 setMyCount를 이용해서 상태의 값을 바꿀 수도 있다. 이러면 다음 렌더링에서 useState 가 돌려주는 상태 값은 바꾼 값이다. 일개 함수가 기억까지 한다는 말이다.

리액트가 각 상태의 정체성을 모름에도 useState의 재실행에 올바른 상태값을 돌려주는 것은 리액트가 훅들의 ‘호출 순서’를 기억하기 때문이다. 이건 리액트 공식 문서에도 간단하게 나와 있고, 이 글의 주제와는 약간 다른 내용이니 넘어가겠다.

 

그렇다면 단순한 함수처럼 생긴 useState는 어떻게 여러 번 호출해도 본인의 이전 상태값을 기억하고 있는 걸까? 자바스크립트에는 함수에 기억력을 부여할 수 있는 기법이 있다. 클로저를 이용하면 된다. 이 글에서는 클로저가 무엇인지는 알고 있다는 가정 하에 넘어가도록 하겠다 (대충 클로저로 카운터를 만들 수 있으면 충분하다).

리액트 useState 살펴보기

리액트에서 useState가 정의된 부분을 살펴보자. function useState로 검색하면 세 군데가 나오는데, 이게 원본인 것 같다.
실제 코드 보러가기

packages/react/src/ReactHooks.js

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

 

천재들의 엄청난 추상화에 머리가 어지럽다. useState는 사실 dispatcher에서 왔다고 한다.
실제 코드 보러가기

packages/react/src/ReactHooks.js

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return ((dispatcher: any): Dispatcher);
}

 

dispatcher는 일종의 객체에서 온 듯하다. 조금 더 가보자.
실제 코드 보러가기

packages/react/src/ReactCurrentDispatcher.js

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

export default ReactCurrentDispatcher;

 

dispatcher는 전역에 하나만 존재한다! 즉 resolveDispatcher, useState 는 본인 상위 스코프에 위치한 ReactCurrentDispatcher에서 dispatcher를 가져오므로 클로저라고 볼 수 있다. 하나로 묶어보면 이런 느낌이 되지 않을까?

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return ((dispatcher: any): Dispatcher);
}

function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

 

물론 dispatcher 안의 useState가 클로저로 동작하는지는 알 수 없다. 궁금해서 조금 더 들어가 봤었는데 몬드리안도 울고 갈 추상화에 도망갔다. 우선은 React.useState에서 dispatcher를 가져오는 방식은 클로저라고 이해하면 될 것 같다.

클로저로 useState 만들기

useState가 클로저를 이용한다는 건 알았다. 하지만 어떤 명제가 참이라고 해서 그 역도 반드시 참은 아닌 법. 클로저를 이용해서 useState를 만들 수 있을까?

사실 구글에 검색해보면 굉장히 다양한 예시들이 나온다. 대충 이런 느낌으로 만들 수 있을 것이다.

let prevState: unknown;

const fakeUseState = <T>(initialValue?: T) => {
  const state = (prevState ?? initialValue) as T;
  const setState = (nextState: T) => {
    prevState = nextState;
  };

  return [state, setState] as const;
};

 

이걸 export 하면 전역 prevState가 하나라서 모두가 공유하기 때문에 딱 한 군데에서만 쓸 수 있다는 문제가 있다. 일단 지금은 딱 한 상태만을 위한 것이라고 하자.

그럼 이걸 사용하면 실제 리액트의 useState처럼 사용할 수 있는 걸까? 한번 실험해보자.

import { useState } from 'react';
import { fakeUseState } from './fakeUseState';

function App() {
  const [myCount, setMyCount] = fakeUseState(0);
  const [yourCount, setYourCount] = useState(0);

  return (
    <>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setMyCount(myCount + 1)}>my count is {myCount}</button>
        <button onClick={() => setYourCount(yourCount + 1)}>your count is {yourCount}</button>
      </div>
    </>
  );
}

실험을 위해 vite의 기본 세팅에서 카운터를 하나 더 추가했다. 여기서 myCount를 fakeUseState로 바꿔보면.. 당연히 안 된다! 왜일까?

 

이 가짜 useState를 리액트에 당장 적용하기에는 두 가지 문제가 있다.

  1. 리액트의 useState가 갖는 중요한 기능 중 하나는 상태가 변한 이후 컴포넌트의 리렌더링을 일으킨다는 것이다. 내가 만든 가짜는 그런 걸 모른다.
  2. setState를 메모리의 새로운 주소에 새로 생성한다. useCallback 안에서 setState를 하는 경우 등등을 대비해 리액트는 useState 에서 반환하는 상태 설정 함수가 항상 같은 메모리 주소를 참조함을 보장한다. 하지만 가짜는 그렇지 않다. (물론 이 문제는 setState 역시 fakeUseState 밖으로 빼서 해결이 가능하긴 하다)

 

그리고 실제 useState와의 차이도 있다.

  1. 모든 컴포넌트가 하나의 전역 변수를 공유하는 셈이 된다.
  2. 컴포넌트의 마운트/언마운트 등의 행위가 일어나도 초기화되지 않는다.

실제 useState와의 차이를 해결하려면 갈 길이 멀다. 우선은 당장 적용할 때 생기는 두 가지 문제를 해결해서 컴포넌트에 붙이는 걸 목표로 해 보자.

useSyncExternalStore 의 도움을 받는다면

리액트 컴포넌트를 비(非) 리액트 코드와 연결하는 법은 크게 두 가지, useEffectuseSyncExternalStore이다. 내 생각에 useEffect 는 리액트 상태의 변화를 외부와 연동(Syncronize)하는 것이고, useSyncExternalStore는 반대로 외부의 변화를 리액트 상태로 둔갑시키는 역할에 가깝다. 따라서 후자를 이용해 fakeUseState를 App과 연결할 것이다.

useSyncExternalStore의 작동 방식은 크게 네 가지로 나눌 수 있다.

 

  1. 컴포넌트는 저장소에‘구독’할 수 있다.
  2. 외부 저장소는 `구독자’들에게 ‘알람’을 보낼 수 있다.
  3. 외부 저장소는 현재 본인의 상태를 찍은 ‘스냅샷’을 반환할 수 있다.
  4. 컴포넌트는 이전 스냅샷과 현재 스냅샷을 Object.is로 비교해 다르다면 리렌더링한다.

따라서 fakeUseState와 이 네 가지 기능을 연결해주는 저장소를 만들면 된다. fakeUseState의 prevState를 unknown으로 줘서 그냥 저장소를 만들면 타입이 박살나기 때문에 '특정 타입 전용 저장소를 만들어주는 함수'를 구현하는 방향으로 진행했다. 최종 결과물은 맨 아래에 주소를 달아 두었다.

1. 구독

먼저 구독 기능을 만들어보자. 이건 쉽다. 구독자의 타입은 평범한 콜백 함수이다.

‘구독하기’는 단순히 구독자 목록에 구독자를 추가하고, 반환값으로 해당 구독자의 구독 취소 함수를 내보내면 된다.

type Subscriber = () => void;

const makeStore = <T>(initialValue?: T) => {
  const subscribers: Set<Subscriber> = new Set();

  const subscribe = (subscriber: Subscriber) => {
    subscribers.add(subscriber);
    return () => {
      subscribers.delete(subscriber);
    };
  };
};

2. 알람

알람 설정도 쉽다.

아까 구독자가 ‘콜백 함수’라고 했다. 알람은 구독자들을 한 번씩 ‘불러’ 주면 된다.

type Subscriber = () => void;

const makeStore = <T>(initialValue?: T) => {
  const subscribers: Set<Subscriber> = new Set();

  const subscribe = (subscriber: Subscriber) => {
    subscribers.add(subscriber);
    return () => {
      subscribers.delete(subscriber);
    };
  };

  const notify = () => subscribers.forEach((subscriber) => subscriber());
};

3. 스냅샷

스냅샷을 찍는다는 건 가짜 useState에서 적었던 return [state, setState] as const; 이걸 반환한다는 뜻이다. 하지만 매번 새로운 배열을 생성한다면 메모리 주소가 달라져 같은 상태임에도 다시 리렌더링이 일어나 무한 순환에 빠지는 치명적인 문제가 생긴다. 상태값 자체는 경우에 따라서 무한 순환에 빠지지 않을 수 있지만, 우리는 배열 형태로 반환하고, 그 배열 안에 함수도 있기 때문에 조심해야 한다.

따라서 클로저를 이용해 state가 같다면 이전에 기억했던 스냅샷을 돌려주도록 구현한다.

type Subscriber = () => void;

const makeStore = <T>(initialValue?: T) => {
  const subscribers: Set<Subscriber> = new Set();
  let cachedState: T;
  let cachedSnapshot: ReturnType<typeof fakeUseState<T>>;

  // 중략

  const getSnapshot = () => {
    const [state, setState] = fakeUseState(initialValue);

    if (!Object.is(cachedState, state)) {
      cachedState = state;
      cachedSnapshot = [state, setStateWithNotification];
    }

    return cachedSnapshot;
  };
};

4. 컴포넌트의 리렌더링

구독자 컴포넌트는 알람이 오면 스냅샷을 알아서 요청한다.
즉 저장소가 할 일은 구독자에게 알람을 보내는 것이다. 알람을 보내는 순간은‘setState가 일어났을 때’이다.

type Subscriber = () => void;

const makeStore = <T>(initialValue?: T) => {
  // 중략

  const getSnapshot = () => {
    const [state, setState] = fakeUseState(initialValue);

    if (!Object.is(cachedState, state)) {
      const setStateWithNotification = (nextState: T) => {
        setState(nextState);
        notify();
      };

      cachedState = state;
      cachedSnapshot = [state, setStateWithNotification];
    }

    return cachedSnapshot;
  };
};

이렇게 setState 이후 notify를 호출하면 구독하는 컴포넌트들이 스냅샷을 요청하고, 이전 스냅샷과의 비교 후 자신의 렌더링 여부를 결정한다.

5. 융합

이제 이 저장소를 useSyncExternalStore에서 쓰려면 구독과 스냅샷 요청 기능을 밖에다 줘야 한다.

const makeStore = <T>(initialValue?: T) => {
  // 중략
  return { subscribe, getSnapshot } as const;
};

드디어 가짜 useState를 완성할 순간이 왔다.
일반 함수로 만든 저장소를 잃어버리지 않기 위해 저장소를 ref에 넣고, useSyncExternalStore를 사용해서 스냅샷을 반환하도록 하면 끝!

const useState = <T>(initialValue?: T) => {
  const {
    current: { subscribe, getSnapshot },
  } = useRef(makeStore(initialValue));
  return useSyncExternalStore(subscribe, getSnapshot);
};

App에서 상태 하나를 새로운 useState로 교체하고 돌려 보면 돌아간다!

const [myCount, setMyCount] = FakeReact.useState(0);
const [yourCount, setYourCount] = useState(0);

 

후기

 

리액트 만든사람들 대단하다.

덕분에 useSyncExternalStore 재밌게 배워간다.

 

자료

 

'저장소를 만드는 함수' 기능은 스택오버플로우의 이 답변을 참고했다.

useSyncExternalStore

 

예제 코드

예제 배포