React Hooks

 함수형 컴포넌트에선 Hooks를 사용해 상태를 관리합니다. Hooks를 이용해 상태를 변경하고 Hooks는 이를 감지하여 컴포넌트를 리렌더링시킵니다. React 내부적으로 이러한 과정들이 어떻게 일어나는지 모른채 그저 편하게 사용해왔지만, 이러한 과정들을 블랙박스로 남겨두고 Hooks를 사용하기엔 찝찝한 기분이 듭니다. 이를 해소하기 위해 찾아본 영상이 Can Swyx recreate React Hooks and useState in under 30 min? 입니다. 이 영상에선 React의 useState와 useEffect Hooks를 직접 구현해보면서 Hooks는 마법이 아니라 단지 배열이라는 사실을 알려줍니다.

하지만, 과연 실제로도 hooks가 배열로 구현되어 있을까요?

 본 포스터에선 hooks가 어떻게 구현되어 있는지 리액트 v16.12.0 패키지의 코드를 직접 확인해보며 알아보겠습니다.

본 포스트는 React 톺아보기 - 03. Hooks_1를 참고하여 작성되었습니다.

1. useState 찾아가기

가장 대표적인 hook인 useState를 어디서 가져오는지 추적해보겠습니다.

리액트 코어의 useState

useStateReactHooks로 부터 import 하고 있습니다.

react > React.js

import {
  useCallback,
  useEffect,
  useState,
} from './ReactHooks';

const React = {
  // ...
  useCallback,
  useEffect,
  useState,
  // ...
  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: ReactSharedInternals,
};

ReactHooks.js 파일을 확인해보니 useState함수가 있습니다.

react > ReactHooks.js

import ReactCurrentDispatcher from './ReactCurrentDispatcher';

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;

  return dispatcher;
}

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

 이 함수의 반환값은 dispatcher의 메서드이므로 dispatcher에 할당된 ReactCurrentDispatcher.current가 무엇인지 확인해봐야 합니다.

react > ReactCurrentDispatcher.js

import type {Dispatcher} from 'react-reconciler/src/ReactFiberHooks';

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

export default ReactCurrentDispatcher;

 하지만, null이 할당되어 있군요.. 그럼 도대체 어디서 dispatcherReactCurrentDispatcher.current의 메서드를 useState로 만들어주는 것일까요?

리액트 코어에서 Hook이 없는 이유

 이에 대한 답을 찾기 전에 현재 저희가 둘러보고 있는 React 코어 패키지에 대해 생각해봅시다. react 패키지의 역할은 컴포넌트 정의와 관련된 모듈을 제공하는 것입니다. 컴포넌트는 React Element Tree를 반환하게끔 정의되고요.

아래 익숙한 코드를 보겠습니다.

const App = () => {
  return (
    <div>
      App Component
    </div>
  )
}

반환값의 JSX 문법은 React Element를 생성하는 React.createElement로 변환됩니다.

React Element란? 컴포넌트의 정보를 담고 있는 모델 객체입니다.
JSX 문법이 React.createElement 함수 호출이며 반환값은 다음과 같습니다.

{
  $$typeof: Symbol(react.element),
  key: null,
  props: {children: "App Component"},
  ref: null,
  type: "div",
}

다음 기회에 React Element와 Component에 대해 더 자세히 설명드리겠습니다.

앞서 말했듯이 react 패키지는 컴포넌트 정의와 관련된 모듈을 제공해야하기 때문에 createElement 함수를 제공합니다.

react > React.js

import { createElement } from './ReactElement';

const React = {
  createElement: __DEV__ ? createElementWithValidation : createElement,
};

createElementReactElement에 정의되어 있습니다.

react > React.js

export function createElement(type, config, children) {
  let propName;

  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // ...

  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    props,
  );
}

 react 코어 패키지는 컴포넌트를 정의할 수 있도록 도와줄 뿐 react 패키지 내에서는 컴포넌트를 호출하거나 렌더링하는 것엔 관여하지 않습니다.

 또한 react 패키지의 특이한 점은 다른 패키지들에 대한 의존도를 가지고 있지 않습니다.
간단히 말해서 react 패키지 코드 내에선 다른 패키지의 모듈을 불러오지 않습니다.

 잠시 react 패키지에 대한 설명으로 빠졌는데, 다시 돌아와 react 패키지엔 hook에 대한 구체적인 코드가 없는 이유를 알아보겠습니다.

 hook은 앞서 말한 react 패키지의 담당 역할과는 거리가 있습니다. hook은 컴포넌트가 호출 되고 렌더링되는 과정에서 역할을 수행합니다. 하지만, react 패키지는 컴포넌트를 호출하거나 렌더링하는 것엔 관여하지 않습니다. 아울러 react 패키지는 다른 패키지에 대한 의존도를 갖고 있지 않기 때문에 한 가지 추론을 해볼 수 있습니다.

hook을 담당하는 외부의 다른 패키지가 react에게 hook을 주입해주고 있다!

여기서의 다른 패키지는 무엇일까요?
hook이 컴포넌트 호출과 렌더링에 관련되어 있으므로 이들과 연관된 패키지일 것입니다.

정답은 바로 reconciler 패키지 입니다.

reconciler 패키지는 컴포넌트 호출부터 VDOM을 재조정하는 렌더링을 담당합니다.

렌더링은 엄밀히 말해 컴포넌트가 호출되고 그 결과가 VDOM에 반영되기 까지의 일련의 과정을 의미합니다.

그럼 여기서 아래와 같이 정리를 해보겠습니다.

react 패키지는 hook을 담당하지 않고 다른 패키지들에 대한 의존도를 갖고 있지 않기 때문에  
외부에서 hook을 주입받고 이를 가져와야 한다.  
따라서 hook을 담당하는 **reconciler**가 react 패키지 외부에서 hook을 주입해준다.  

그렇다면, reconciler가 react 패키지의 ReactCurrentDispatcher를 직접 불러온 뒤 주입해주는 방식일까요?

그렇지 않습니다.

 실제로는 리액트 패키지간의 공유 폴더를 이용합니다. 그리고 리액트의 모든 패키지들이 공유하는 폴더가 바로 shared 패키지입니다.

shared 패키지

 앞서 말했듯이 react 패키지는 외부 모듈에 대한 의존도를 갖고 있지 않습니다. 대신 공유 폴더에 모듈을 올려두고 그곳에서 주입이 일어납니다. 그 과정을 살펴보겠습니다.

 react 패키지는 ReactSharedInternals에 자신의 모듈들을 올려둡니다. ReactSharedInternals는 외부에서 주입받길 기다리는 모듈들의 대기소 같은 곳입니다.

react > ReactSharedInternals.js

import ReactCurrentDispatcher from './ReactCurrentDispatcher';
// ...

const ReactSharedInternals = {
  ReactCurrentDispatcher,
  // ...
};

export default ReactSharedInternals;

이제 shared 패키지의 ReactSharedInternals에서 올려둔 모듈들을 가져옵니다.
shared는 공유 폴더기 때문에 외부 모듈이 shared의 모듈들을 마음껏 불러올 수 있습니다.

shared > ReactSharedInternals.js

import React from 'react';

const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; // core의 ReactSharedInternals.js

if (!ReactSharedInternals.hasOwnProperty('ReactCurrentDispatcher')) {
  ReactSharedInternals.ReactCurrentDispatcher = {
    current: null,
  };
}

export default ReactSharedInternals;

그러면 실제로 hook을 주입해주는 곳을 확인하려면 shared의 ReactSharedInternals.js를 import하여 ReactCurrentDispatcher를 사용하는 곳을 찾으면 됩니다!

미리 말하자면 이곳은 바로 reconciler의 ReactFiberHooks.js이며
지금까지의 과정을 모식도로 정리해보겠습니다.

모식도1

쉽게 말해 react 코어 패키지의 ReactCurrentDispatcher모듈을 shared 패키지의 ReactSharedInternals올려 둬서 외부패키지가 이를 import하여 hook을 주입할 수 있도록 해둔겁니다. 실제론 reconciler가 ReactCurrentDispatcher를 import합니다.

실제로 hook을 주입하는 reconciler

드디어 hook의 실체를 확인할 수 있는 reconciler 패키지에 도달하였습니다!

천천히 살펴봅시다.

reconciler > ReactFiberHooks.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import ReactSharedInternals from 'shared/ReactSharedInternals';

const { ReactCurrentDispatcher, ReactCurrentBatchConfig } = ReactSharedInternals;

// The work-in-progress fiber. I've named it differently to distinguish it from
// the work-in-progress hook.
let currentlyRenderingFiber: Fiber | null = null;

// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let nextCurrentHook: Hook | null = null;

export function renderWithHooks(
  current: Fiber,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime
) {
  // ...
  currentlyRenderingFiber = workInProgress
  nextCurrentHook = current !== null ? current.memoizedState : null

  ReactCurrentDispatcher.current =
    nextCurrentHook === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate

  let children = Component(props, refOrContext) // 실제로 컴포넌트를 호출하고 있다!

  const renderedWork = currentlyRenderingFiber
  renderedWork.memoizedState = firstWorkInProgressHook

  ReactCurrentDispatcher.current = ContextOnlyDispatcher

 
  currentlyRenderingFiber = null;
  // ...
}

ReactFiberHooks.js의 직관적인 이름을 가진 renderWithHooks함수는 hooks와 함께 컴포넌트를 render하는 것 같습니다.

먼저 25번째 줄을 보면 전역 변수 currentlyRenderingFibercurrent가 아닌 workInProgress로 잡혀있는데요,
current는 이미 DOM에 반영된 VDOM의 트리이며, workInProgress는 재조정이 일어나고 있는 VDOM의 트리입니다.

current와 workInProgress: React 톺아보기 - 02. Intro 참고하기

 전에 언급했지만 reconciler는 컴포넌트를 호출하여 이 결과를 VDOM에 반영합니다. 재조정된 VDOM은 실제 DOM에 반영되고, 실제 DOM에 반영된 VDOM의 tree를 current라고 말합니다. 반면 실제 DOM에 반영되기 전 재조정되고 있는 VDOM의 tree는 workInProgress입니다.

 따라서, reconciler는 current 트리에 관심이 없고 오직 workInProgresscurrentlyRenderingFiber로 잡아두게 둔 상태에서 작업을 수행합니다.

 다음으로 26번째 줄을 보기 전에 28번째 줄을 먼저 확인해보겠습니다. 드디어 ReactCurrentDispatcher.current에 hook을 주입해주고 있습니다! 다만, 어떤 조건에 따라 HooksDispatcherOnMount 또는 HooksDispatcherOnMount를 주입해주고 있는데요, 컴포넌트가 Mount 상태인지 Update 상태인지에 따라 다른 hooks를 주입하는 것을 알 수 있으며, 실제 구현채는 아래와 같습니다.

reconciler > ReactFiberHooks.js

// mount
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  /*...*/
};

// update
const HooksDispatcherOnUpdate: = {
  useState: updateState,
  useEffect: updateEffect,
  /*...*/
};

// invalid hook call
export const ContextOnlyDispatcher: Dispatcher = {
  useState: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  /*...*/
};

ContextOnlyDispatcher라는 hook도 있는데요, 이는 36번째 줄에서 ReactCurrentDispatcher.current에 할당됩니다.
하지만, 이상한 점은 이미 HooksDispatcherOnMount 또는 HooksDispatcherOnMount가 할당됐는데 ContextOnlyDispatcher를 넣어준다는 것입니다.

 이유는 31번째 줄을 보면 알 수 있습니다. renderWithHooks라는 함수에 걸맞게 컴포넌트를 호출하고 있습니다. ContextOnlyDispatcher의 역할은 컴포넌트가 호출 된 뒤 개발자가 실수로 훅을 호출하는 경우를 대비하여 에러를 던져줍니다.

아래는 throwInvalidHookError의 정의입니다.

reconciler > ReactFiberHooks.js

function throwInvalidHookError() {
  invariant(
    false,
    'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
      ' one of the following reasons:\n' +
      '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
      '2. You might be breaking the Rules of Hooks\n' +
      '3. You might have more than one copy of React in the same app\n' +
      'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.',
  );
}

2번은 Hook 규칙을 위반했을 때 에러가 발생할 수 있다는 점을 말하고 있습니다!

 다시 돌아와서 이번엔 reconciler가 mount 상태인지 update 상태인지 판단하는 조건을 알아보겠습니다.
위의 ReactFiberHooks.js 코드를 조금 축약하겠습니다.

reconciler > ReactFiberHooks.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let currentlyRenderingFiber: Fiber | null = null;
let nextCurrentHook: Hook | null = null;

export function renderWithHooks(
  current: Fiber,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime
) {
  // ...
  currentlyRenderingFiber = workInProgress
  nextCurrentHook = current !== null ? current.memoizedState : null

  ReactCurrentDispatcher.current =
    nextCurrentHook === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate

  let children = Component(props, refOrContext)

  const renderedWork = currentlyRenderingFiber
  renderedWork.memoizedState = firstWorkInProgressHook

  ReactCurrentDispatcher.current = ContextOnlyDispatcher

 
  currentlyRenderingFiber = null;
  // ...
}

 조건은 바로 nextCurrentHooknull인지 아닌지 입니다. nextCurrentHook는 14번째 줄에서 결정되는데 currentnull이 아니라면 current.memoizedState를 할당합니다.

currentnull이 아니라는 것은 실제 DOM에 반영된 VDOM의 current tree가 존재한다는 것입니다! 따라서, mount를 완료한 상태이므로 (nextCurrentHooknull이 아니게 되어) ReactCurrentDispatcher.currentHooksDispatcherOnUpdate를 주입하는 것입니다.

역으로 currentnull이라면 (즉, mount 상태라면) HooksDispatcherOnMount을 주입해야 합니다.

지금까지의 내용을 추가적으로 반영한 모식도는 다음과 같습니다.

모식도2

2. Hook은 어떻게 구현되어 있고 어떻게 생성될까?

컴포넌트가 마운트 상태일 때 useStatemountState가 매핑되는 것을 알았습니다.

reconciler > ReactFiberHooks.js

// mount
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  /*...*/
};

그렇다면 mountState가 어떻게 생겼는지 파헤쳐 봅시다.

reconciler > ReactFiberHooks.js

type BasicStateAction<S> = (S => S) | S;

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();

  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  hook.memoizedState = hook.baseState = initialState;

  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });

  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    // Flow doesn't know this is non-null, but we do.
    ((currentlyRenderingFiber: any): Fiber),
    queue,
  ): any));
  
  return [hook.memoizedState, dispatch];
}

useState에 값 또는 함수를 전달할 수 있으며 return값으로 [state, setState]와 동일한 형태를 반환하고 있습니다.
hook 지역 변수에 mountWorkInProgressHook()를 할당하고 있는데, 이 함수를 찾아보면 서론에서 말한 hook이 과연 배열인지 아니면 다른 자료 구조로 구현되어 있는지 확인해볼 수 있습니다.

베열이 아닌 연결 리스트!

reconciler > ReactFiberHooks.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let firstWorkInProgressHook: Hook | null = null;
let workInProgressHook: Hook | null = null;

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    queue: null,
    baseUpdate: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    firstWorkInProgressHook = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

우리가 useState를 호출할 때 마다 mountWorkInProgressHook가 실행되고 hook 객체를 생성해주고 있습니다.
또한, hook객체의 next 속성을 통해 hook을 연결시켜주는 것을 확인할 수 있는데, 이를 그림으로 쉽게 표현해보겠습니다.

아래와 같이 useState를 세번 호출하는 상황이 있습니다.

const [first, setFirst] = useState('first');
const [second, setSecond] = useState('second');
const [third, setThird] = useState('third');

먼저 useState('first')를 호출해보겠습니다. 초기엔 workInProgressHook 전역 변수가 null로 할당되어 있으므로 15~17 줄이 실행되고 그 결과는 다음과 같습니다.

hook_연결리스트_1

연이어 useState('second')를 호출하면 18~21 줄이 실행되고 그 결과는 다음과 같습니다.

hook_연결리스트_2

firstWorkInProgressHook는 첫번째 hook을 가리키고 새로운 hook이 만들어질 때 마다 다음 노드로 이어지는 것을 확인할 수 있습니다.
마지막으로 useState('second')를 호출한 결과입니다.

hook_연결리스트_3

영상에선 hooks가 마법이 아닌 배열이란 것을 알 수 있었습니다.

하지만, 실제로는 hooks가 배열이 아닌 연결리스트로 구현되어 있습니다!

각각의 Hook에 대한 상태 변경(Update) 정보를 담는 Queue!

mountState의 다른 부분들도 마저 확인해보겠습니다.

reconciler > ReactFiberHooks.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type BasicStateAction<S> = (S => S) | S;

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();

  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  hook.memoizedState = hook.baseState = initialState;

  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });

  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    // Flow doesn't know this is non-null, but we do.
    ((currentlyRenderingFiber: any): Fiber),
    queue,
  ): any));
  
  return [hook.memoizedState, dispatch];
}

hook 객체의 memoizedState에 초깃값을 넣어주고 있는데, memoizedState은 해당 hook에 적용된 마지막 상태 값을 저장하고 있습니다. 그리고 hook객체의 queue도 있는데 이것의 역할은 무엇일까요?

 저희는 useStatesetState함수를 사용하여 상태값들을 update합니다.
update할 때마다 상태 변경 update 정보를 담고있는 객체가 생성되는데요, 이것이 queue에 쌓이게 됩니다.

setState가 비동기로 실행된다는 말을 들어보셨을겁니다. 우리가 setState를 호출할 때마다 리액트는 update된 정보를 곧바로 반영하지 않고, 어느 정도 일정 시간이 지난 뒤 update 정보들을 일괄적으로 처리합니다. 만약 setState를 한꺼번에 여러번 호출했다면, 생성된 update 객체들이 해당 hook의 queue에 쌓이게 됩니다.
그리고 queue에 쌓여있는 update들을 순차적으로 처리하여 최종 state를 반영합니다.

즉, queue는 해당 hook에 대한 update 객체들을 담고 있습니다!

마지막으로 다음 코드를 실행해봤을 때, hook 연결 리스트와 각각의 hook에 대한 queue가 어떻게 되는지 그림으로 표현해보겠습니다.

const [first, setFirst] = useState(0);
const [second, setSecond] = useState(0);
const [third, setThird] = useState(0);

setFirst((prev) => prev+1); // update A
setFirst((prev) => prev+1); // update B
setFirst((prev) => prev+1); // update C

setSecond((prev) => prev+1); // update D
setSecond((prev) => prev+1); // update E

hook_queue

queue의 last는 마지막 update 객체를 가리키고 update 객체끼리는 원형 연결 리스트 형태를 띄고 있습니다. 이 이유에 대해선 추후에 알아보겠습니다.

마치며

 hook이 과연 어떻게 구현되어있는지 알기 위해 가장 대표적인 useState hook을 코드 레벨에서 직접 찾아가 보았습니다. 이 과정을 통해 리액트 패키지들의 역할을 알 수 있었고 특히, react 패지키는 외부 패키지에 대한 의존도를 가지지 않아 shared 패키지를 활용하여 reconciler 패키지가 외부에서 hook을 주입한다는 것을 알게 되었습니다.
 또한, mount와 update시 할당되는 useState 구현채가 다르단 것과 여러 hook들 끼리는 연결리스트로 이어져 있으며 각각의 hook에 대한 상태 변경 정보를 담은 update 객체가 queue로 관리된다는 것을 배웠습니다.

Reference