2023-10-15: useState 해부하기 (1)
🤔 나는 useState를 이해하고 있나?
문득 그런 생각이 들었다. 그런 생각이 들었던 건, 예전의 예차니즘 님께서 말씀하신 그 말이 떠올라서였는데..
클로저에 대해서 물어볼 때, useState가 왜 클로저인지를 말할 수 있어야 한다는 그 말씀.
되게 기억에 남아있었고, 머릿속으로도 꽤 깊게 박혀있던 말이었지만, 그 이후에 공부한다고 해놓고 머릿속에서는 그냥 스쳐 지나가 버렸다.
얼마나 지났을까, 요즘 어떤 공부를 해야할지 막막함이 생긴 와중에 여러 사람들과의 대화를 통해서 손에 닿는 것을 필요에 따라 공부해보는 것이 좋다는 의견을 듣게 되었고, 하나씩 공부를 하려던 찰나에 때마침 이전에 useState에 관한 궁금증이 떠올랐다.
이번에야 말로 좀 이해해봐야겠다 싶어서 다른 사람들이 공부했던 자료와 함께 useState에 대해 이해해보는 시간을 우선적으로 가져보고자 한다.
❗ 우리가 알고 있는 useState의 쓰임.
React에서 useState를 쓰는 사람이라면 다들 어떻게 쓰는지는 다 알고 있을 것이다.
const [state, setState] = useState(undefined);
state는 현재의 상태를 가리키고, setState는 상태를 변할 때 쓰이는 값이다.
그리고 useState의 인자는 state의 초기값을 가리킨다는 걸 알고 있다.
그러면, 여기서 궁금해지는 건 두 가지인데…
useState의 인자를 받아와서 어떻게state에 초기값 설정을 하게 되는 걸까?setState는 어떻게 해서state의 값을 변경하게 해주는 걸까?
이 두 가지의 궁금증을 파헤치기 위해서 이번 글을 쓰게 됐다.
❓ useState는 어떤 구조로 이루어져 있을까?
콘솔창에 console.log(useState)를 해서 확인해보면 아래와 같은 코드를 확인할 수 있다.
function useState(initialState) { var dispatcher = resolveDispatcher(); return dispatcher.useState(initialState); }
구조를 보자면 dispatcher라는 변수에 resolveDispatcher라는 함수를 통해서 우리가 사용하는 useState를 적용시켜주는 듯 하다.
좀 더 들어가볼까? resolveDispatcher라는 함수는 어떤 내용일까?
function resolveDispatcher() { var dispatcher = ReactCurrentDispatcher.current; { if (dispatcher === null) { error('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://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.'); } } // 렌더링 단계 외부에서 액세스하면 null 액세스 오류가 발생합니다. // hot path에 있기 때문에 의도적으로 자체 오류를 발생시키지 않습니다. // 또한 인라인을 보장하는 데 도움이 됩니다. // * hot path: 컴파일러 내에서 대부분의 실행 시간이 소요되고 잠재적으로 자주 실행될 수 있는 // 코드 실행 경로라고 합니다. // react에서는 react.development.js가 그런 듯 합니다. return dispatcher; }
이 코드를 보면, React를 처음 사용했을 때 간간히 볼 수 있던, 우리가 렌더링을 할 때에 useState를 선언하는 위치가 컴포넌트의 본체가 아니라면 항상 보게 되는 에러인 ‘Invalid Hook call’ 에러가 호출되는 것도 볼 수 있다.
하지만, 우리가 찾는건 위의 함수 resolveDispatcher의 반환값이니, 이 쪽에 초점을 두고 보자. resolveDispatcher 함수 안에 있는 변수 dispatcher가 받아오는 값, ReactCurrentDispatcher라는 함수의 속성인 current를 반환값으로 받고 있다.
그래서 한 번 더 다이브하여 ReactCurrentDispatcher를 살펴봤는데…
// 현재 디스패처를 추적합니다. var ReactCurrentDispatcher = { /** * @internal * @type {ReactComponent} */ current: null };
위와 같이 나와만 있다…

어찌됐든 ReactCurrentDispatcher 안에 있는 current 값을 받아와서 변경해주는 것 같은데, 이런 내용 만으로 이해하기는 너무 부족하다…
혹시 ReactSharedInternals로 검색하면 뭔가 답이 있을까 싶어 찾아보니…
var ReactSharedInternals = { ReactCurrentDispatcher: ReactCurrentDispatcher, ReactCurrentBatchConfig: ReactCurrentBatchConfig, ReactCurrentOwner: ReactCurrentOwner }; var ReactSharedInternals$1 = { ReactCurrentDispatcher: ReactCurrentDispatcher, ReactCurrentOwner: ReactCurrentOwner, ReactCurrentBatchConfig: ReactCurrentBatchConfig, Scheduler: Scheduler }; . . . exports.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = ReactSharedInternals;
ReactSharedInternals 속 메소드로 적용되어 있는 듯 하다.
객체로 만들었으니 외부에서 호출하나 싶어서 추적해봤지만, node_module 폴더 내에서 발견하지 못했다… 내가 못 찾는건가?

우선 여기까지만 봤을 때 정리를 조금 짚고 가자.
ReactCurrentDispatcher.current의 값이resolverDispatcher()의 반환값이다.- 그리고 이 반환값이 우리가 사용하는
useState에 적용된다. - 그리고
ReactCurrentDispatcher는ReactSharedInternals에서 관리되고 있다.
막막함에 어떻게 할까 싶다가, setState 함수를 콘솔에 찍어 확인을 해보니..
function dispatchSetState(fiber, queue, action) { { if (typeof arguments[3] === 'function') { error("State updates from the useState() and useReducer() Hooks don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().'); } } var lane = requestUpdateLane(fiber); var update = { lane: lane, revertLane: NoLane, action: action, hasEagerState: false, eagerState: null, next: null }; if (isRenderPhaseUpdate(fiber)) { enqueueRenderPhaseUpdate(queue, update); } else { var alternate = fiber.alternate; if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) { var lastRenderedReducer = queue.lastRenderedReducer; if (lastRenderedReducer !== null) { var prevDispatcher; { prevDispatcher = ReactCurrentDispatcher$1.current; ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV; } try { var currentState = queue.lastRenderedState; var eagerState = lastRenderedReducer(currentState, action); update.hasEagerState = true; update.eagerState = eagerState; if (objectIs(eagerState, currentState)) { enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update); return; } } catch (error) { } finally { { ReactCurrentDispatcher$1.current = prevDispatcher; } } } } var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, fiber, lane); entangleTransitionUpdate(root, queue, lane); } } markUpdateInDevTools(fiber, lane); }

갑자기 내용이 아득하기만 하다.. (하지만 다음에 살펴볼 내용이다.)
혹시 저 함수명이 키워드가 되는 게 있을까? 싶어서 무작정 ‘dispatchSetState’를 추적해봤다.
이게 힌트가 될까 싶어서, 무작정 코드를 검색해 추적해봤는데…
function mountStateImpl(initialState) { var hook = mountWorkInProgressHook(); if (typeof initialState === 'function') { // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types initialState = initialState(); } hook.memoizedState = hook.baseState = initialState; var queue = { pending: null, lanes: NoLanes, dispatch: null, lastRenderedReducer: basicStateReducer, lastRenderedState: initialState }; hook.queue = queue; return hook; } function mountState(initialState) { var hook = mountStateImpl(initialState); var queue = hook.queue; var dispatch = **dispatchSetState**.bind(null, currentlyRenderingFiber$1, queue); queue.dispatch = dispatch; return [hook.memoizedState, dispatch]; }
오… mountState라는 함수에서 해당 함수를 bind해 새로운 함수인 dispatch를 만들어내고 있다.
그리고 이 mountState에서 반환되는 값을 보면 우리가 useState에서 많이 본 배열 형태의 값을 들고 있는 것을 볼 수 있다.
혹시나하여, 마지막으로 mountState를 키워드로 좀 더 검색해본 결과,
재밌는 결과들을 발견하게 됐는데..
. . . var ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; . . . var ReactCurrentDispatcher$1 = ReactSharedInternals.ReactCurrentDispatcher, . . . var HooksDispatcherOnMountWithHookTypesInDEV = null; var InvalidNestedHooksDispatcherOnMountInDEV = null; . . . HooksDispatcherOnMountWithHookTypesInDEV = { . . . useState: function (initialState) { currentHookNameInDev = 'useState'; updateHookTypesDev(); var prevDispatcher = **ReactCurrentDispatcher$1.current**; ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnMountInDEV; try { return mountState(initialState); } finally { ReactCurrentDispatcher$1.current = prevDispatcher; } } . . . }
전부 위에서 우리가 살펴보고 있던 키워드들이 여기에 모여있었다.
여러 키워드들이 섞여있지만 그거까지 다 보면 솔직히 모든 코드를 다 살펴봐야할 판이니,
useState 만을 알기 위해서 추적해왔던 부분들만 집중해서 보자면,
ReactSharedInternals에서 관리되는ReactCurrentDispatcher는 변수ReactCurrentDispatcher$1에 할당했다.- 할당한 값은 특정 변수의 메소드인
useState속에서 변수prevDispatcher의 값으로 쓰인다. try / finally문을 통해서,try로mountState함수의 실행을 적용하고 이에 대한 결과와 상관없이,finally에서ReactCurrentDispatcher의 current 값을 이전 상태 값을 적용하도록 했다.- 앞서 변수
prevDispatcher에 할당한 값을 다시 적용하는 건 이전 상태값을 백업하기 위함이 아니었을까? 라는 추측이 있다.
- 앞서 변수
🤕 그래서 추적의 결과, useState는?
- useState 훅은
resolverDispatcher()의 반환값을 받는다. resolverDispatcher()가 반환하는 값은ReactCurrentDispatcher.current이다.- 그리고
ReactCurrentDispatcher는ReactSharedInternals에서 관리되고 있으며, - 이를 이용해서
useState메소드 속에 있는mountState에서 해당 값을 반환하고 있다.- 최초, useState 훅의 반환값이
resolverDispatcher.useState()라는 점을 잊지 말자.
- 최초, useState 훅의 반환값이
이번엔 추적하느라 진이 다 빠졌는데, 어찌됐든 mountState에서의 동작이 결과적으로 useState와 연결되는 것을 확인했으니 해당 함수를 좀 더 살펴보고, 그래서 useState가 왜 클로저인데? 하는 부분도 짚어보겠다.
🔖 참고 자료
What's the meaning of "hot codepath" (or "hot code path")?
Hook의 동작원리 파헤쳐보기(React 코드 까보기) 02 - 외부 주입 역할을 하는(의존성 관리) ReactSharedInternals.js와 shared 패키지
Jin's Blog | Frontend Engineer
[React 코드 까보기] useRef는 DOM에 접근할 때 뿐만 아니라 다양하게 응용할 수 있어요.