사툰사툰

useState 동작 원리와 클로저
REACT

useState 동작 원리와 클로저

react에선 어떻게 상태를 관리하고 있는지 useState의 구조와 동작 원리를 파악해 보았습니다.

2024. 2. 14.

1307

🚀 Overview

react@18.2.0, react-dom@18.2.0 기준으로 설명합니다.
공식 사이트를 기반으로 정리하였습니다.

최근 프론트엔드 개발자는 SPA 프레임워크를 빠트릴 수 없을 정도로 가까이 마주합니다. 그 중 대부분은 React 기반으로 이루어져 있습니다. React에선 상태라는 매개체를 도입하여 데이터를 관리합니다. React Hooks이 나온 이후로 클래스 형식이 아닌 함수 형식으로 많이 전환하면서 상태 관리를 할 때 useState를 많이 이용하게 되었는데요. 이 함수는 어떻게 동작하는 것일까요?

⚒️ useState 구조

const [state, setState] = useState(initialState);
// ex) const [count, setCount] = useState(0);

useState구조 분해 할당을 통해 다음 두 가지 아이템을 받습니다.

  1. 현재 상태값을 가진 변수 (state)
  2. 상태를 다른 값으로 바꾸는 함수 (setState)

state에 해당하는 값은 첫 렌더링 때 useState의 파라미터로 받은 초기값(initialState)을 통해 상태를 매칭합니다. 이후 setState를 통해 다른 값을 주입하여 상태를 업데이트하고 리렌더링합니다. 기본적인 useState 동작 방식은 이렇습니다.

상태 업데이트 (batching)

export default function Component() {
	// ✔️ 클릭 이벤트 이후에 상태가 업데이트 됩니다.
	const handleClick = () => {
		setState(state + 1);
	}
	
	return (
		<button onClick={handleClick}>
			버튼
		</button>
	)
}

React는 모든 이벤트 핸들러가 종료된 후에 상태를 업데이트합니다. 이를 Batching이라 합니다. 위의 예시를 통해 단계별로 파악해 봅니다.

  1. 버튼(+1) 클릭
  2. 클릭 이벤트(onClick) 함수 실행
  3. onClick 함수 내 setState 실행
  4. 상태 업데이트 후 리렌더링

이 때, 3번은 클릭 이벤트가 종료된 후에 상태를 업데이트 할 수 있도록 Queue에 담습니다. 이후 모든 이벤트 핸들러가 종료되면 Queue를 실행합니다. Queue에 담긴 상태 업데이트가 끝나면 최종적으로 리렌더링합니다. 이제 state + 1이라는 우리가 원하는 결과물을 볼 수가 있습니다.

// 각각 set 함수는 Queue에 담아 업데이트 합니다.
setState(state + 1);
setState(state + 2);
setState(state + 3); // ✔️ 마지막 setState로 최종적으로 업데이트 합니다.

Batch Update를 할 때 여러 번 같은 set 함수를 실행한다면 마지막에 담긴 set 함수가 최종적으로 업데이트 됩니다. (정말 그런지 밑에서 다시 살펴봅니다)

React 17까지는 이벤트 핸들러에서만 Batch Update가 처리되었습니다. 이벤트 핸들러 외부(Promise, setTimeout 등)에서 Batch Update가 필요할 땐 unstable_batchedUpdates 라는 API를 사용하여 구현해야 했습니다. 이러한 불필요한 과정은 다행히 React 18로 넘어오면서 자동 적용되도록 변경되었습니다.

// ❌ 상태를 직접 수정하면 리렌더링이 일어나지 않습니다.
state = state + 1;
// ✔️ set 함수를 통해 상태를 변경해야 리렌더링이 발생합니다.
setState(state + 1);

let으로 선언한 상태는 재할당이 가능하지만 set 함수를 실행하지 않아 리렌더링이 발생하지 않습니다. (리렌더링이 발생하는 조건은 이후에 다시 확인합니다) React에선 Diffing 알고리즘을 통해 어떤 상태가 변경되었는지 가상돔에서 확인 하고, Batch Update를 통해 변경된 상태를 한꺼번에 실제돔에 업데이트 합니다. 이러한 과정은 효율적인 렌더링을 위함입니다. 여러 번의 렌더링을 일괄적으로 처리함으로써 비용을 줄일 수 있습니다.

한 번에 업데이트 하려면, 변경될 상태의 set 함수를 찾고 업데이트를 해야 합니다. 이 과정이 동기적으로 이루어 진다면, 성능 저하를 야기할 수 있습니다. (set 함수의 실행이 끝날 때까지 다른 작업은 대기할 수 밖에 없고, 불필요한 렌더링이 발생합니다) 그러므로 setState는 비동기적으로 동작하도록 설계되어 있습니다.

강제로 업데이트 (flushSync)

// ⚠️ 강제로 상태를 업데이트 할 수 있지만, 위험합니다.
flushSync(setState(state + 1));

물론 강제로 동기적으로 업데이트 하는 방법도 있습니다. flushSync를 통해 강제할 수 있습니다. flushSync 는 Queue에 담아둔 set 함수와 함께 동기적으로 상태를 업데이트 합니다. 다만 이 방법은 성능 저하가 일어날 수 있으며, Suspense에 담긴 fallback UI 또한 무시될 수 있습니다.

⚒️ setState 동작 원리

// 각각 set 함수는 Queue에 담아 업데이트 합니다.
setState(state + 1); // ❓ Queue에 담겼다면 어디로 갔을까?
setState(state + 2); // ❓ Queue에 담겼다면 어디로 갔을까?
setState(state + 3); // ✔️ 마지막 setState로 최종적으로 업데이트 합니다.

앞에서 살펴본 바로는 여러 개의 setState는 가장 마지막 단계의 상태 변경만 업데이트 됐습니다. 그렇다면 큐에 담긴 이전 setState는 무시된 걸까요? 물론 무시되지 않았습니다 😂. 결과로만 바라봤을 뿐 이전 setState 또한 여전히 동작합니다.

Illustrated by Rachel Lee Nabors
Illustrated by Rachel Lee Nabors
// 상태를 스냅샷처럼 기억합니다
// ⚠️ state는 렌더링 되기 전까지 0의 값(이전 상태값)을 가집니다.
const [state, setState] = useState(0);
setState(state + 1); // ✔️ 0 + 1
setState(state + 2); // ✔️ 0 + 2
setState(state + 3); // ✔️ 0 + 3

React에서는 현재 상태를 스냅샷처럼 기억합니다. setState를 호출할 때 해당 시점의 state를 기준으로 업데이트를 수행합니다. 모든 set 함수가 실행될 때까지 state는 이전 상태를 기억하고 있습니다. 모든 업데이트가 끝난 후, 렌더링을 거쳐 실제 DOM에 변경 사항을 반영한 후 비로소 최신 상태를 가지게 됩니다. set 함수가 실행됐다 하더라도 렌더링 이전엔 상태 업데이트가 반영되지 않기에 주의해야 합니다.

Illustrated by Rachel Lee Nabors
Illustrated by Rachel Lee Nabors

set 함수는 Queue를 통해 모든 이벤트 핸들러가 끝나고 상태를 업데이트한다 말씀드렸습니다. 공식 사이트에선 이를 음식점의 손님과 웨이터에 비유하여 설명합니다. 손님의 주문을 기록하면서 맨 마지막 주문만 받아들이는 원리입니다.

setState((prev) => state + 1); // prev : 0, state : 0
setState((prev) => state + 2); // prev : 1, state : 0
setState((prev) => state + 3); // prev : 2, state : 0

또한 set 함수는 Queue로 연결되어 있으므로 이전 상태를 참조할 수 있습니다. 함수의 형태로 이전의 상태를 인자로 받아 반환 값에 사용할 수 있습니다. (어떻게 참조할 수 있는지 이후에 조금 더 살펴보기로 합니다)

const [state, setState] = useState(0);

const handleClick = () => {
	setState(state + 1); // state에 1을 더합니다
	setTimeout(() => {
		alert(state); // ❓ 어떤 값이 나올까?
	}, 3000); // 3초 후 실행합니다
}

해당 예시를 살펴보면 상태의 원리를 쉽게 파악할 수 있습니다. handleClick 내부에선 setState를 통해 상태를 업데이트합니다. 이후 setTimeout을 통해 3초 뒤 state를 출력합니다. 이 때 state는 어떤 값을 가리킬까요?

React는 상태를 스냅샷처럼 기억하므로, setTimeout이 실행된 시점의 state(0)를 출력하게 됩니다.

// 다음 상태를 변수에 저장합니다
const [state, setState] = useState(0);
const nextState = state + 1;
setState(nextState); // state : 0, nextState : 1

만약 렌더링 이전에 다음 상태를 사용하고 싶다면 변수에 저장하여 사용할 수 있습니다.

불변성을 유지해야 한다

우리는 흔히 React에선 불변성을 유지해야 한다고 자주 언급합니다. 그 이유는 Javascript의 메모리 구조에서 비롯됩니다.

const [array, setArray] = useState(['1', '2']);

useState에 배열을 담고 set 함수를 통해 값을 추가할 때 보통 concat 혹은 spread 연산자를 사용합니다. 위의 예시를 통해 알 수 있듯이 push를 사용하면 리렌더링이 일어나지 않습니다. React에서 리렌더링이 일어나는 조건은 변수를 재할당하여 콜 스택의 메모리가 변할 때 발생합니다.

Javascript 엔진에서는 힙 영역콜 스택 두 가지 영역이 존재합니다. 콜 스택은 전역 변수나 함수 코드 등을 통해 생성한 실행 컨텍스트가 담겨 있으며, 이 과정에서 원시 변수(Primitive Value)를 할당합니다. 힙 영역은 런타임에 동적으로 메모리를 할당하는 공간으로 주로 객체(Reference Value)가 저장됩니다. 객체는 원시 변수와는 달리 크기가 정해져 있지 않기에 런타임에 메모리를 할당합니다. 이 때, 실행 컨텍스트에서 힙에 저장된 객체를 참조합니다.

원시 값은 변경 불가능한 값(immutable value)입니다. 값을 재할당하면 이전 메모리는 무시한 채 콜 스택에 새로운 메모리 영역을 부여하여 값을 할당합니다. 반면에 객체나 배열은 값이 추가되거나 삭제되어도 콜 스택의 메모리는 바뀌지 않습니다. 객체에 할당된 콜 스택의 메모리는 힙 영역의 주소값이기 때문입니다. 객체 내부의 값이 변하면 힙 영역의 메모리만 바뀔 뿐입니다.

push 연산자는 객체의 값을 바꿉니다. 이 때 힙 영역의 메모리 주소를 유지한 채 할당된 메모리 값만 바꿉니다. 즉, 콜 스택이 가리키는 메모리 주소는 동일합니다. 반면에 concat 혹은 spread 연산자를 사용하면 새로운 객체를 생성하므로 새로운 메모리 영역을 부여 받습니다. 콜 스택의 주소값 또한 변합니다. 그리고 이전에 사용한 메모리 영역은 가비지 콜렉터에 의해 제거됩니다.

불변성을 유지해야 한다는 말상태 변경을 추적하기가 쉬워야 한다는 말과 같습니다. 만약 재할당 이외에 변수 값을 변경할 수 있다면 예기치 않게 값이 변경될 수 있기에 리렌더링 기준이 모호해질 것입니다.

✔️
추가 push, unshift concat, spread syntax
삭제 pop, shift splice filter, slice
수정 splice, arr[i] = … map
정렬 reverse, sort use shallow copy (concat, spread syntax)

공식 사이트에선 불변성을 지키기 위한 다음의 가이드를 제시합니다.

set 함수는 클로저로 동작한다

우리는 useState를 사용하여 여러 상태를 저장합니다. 그렇다면 상태를 어떻게 저장하는 걸까요?

useState는 각 함수마다 고유의 상태값을 기억하고 있어야 합니다. React에선 이를 클로저를 통해 해결합니다.

// _val은 useState 밖에서 직접 접근할 수 없습니다.
function useState(initVal) {
	let _val = initVal;
	const state = () => _val;
	const setState = newVal => {
		_val = newVal;
	}
	return [state, setState]
}

JsConf에서 보인 예시를 통해 간단히 살펴볼 수 있습니다. 초기값인 initVal을 Parameter로 받아 private 변수인 _val에 저장합니다. 이제 _val은 함수 내부의 state, setState로만 참조가 가능합니다. 클로저를 통해 함수가 선언될 당시 값을 기억하여 사용하는 원리입니다. 여기서 여러 useState를 사용한다면 각각 독립적인 상태를 가져야 합니다. 이와 같은 구조라면 하나의 _val만 존재하므로 여러 상태를 구현할 수 없습니다.

// hooks 배열 안에 상태를 저장합니다.
let hooks = [];
let idx = 0;

function useState(initVal) {
	const state = hooks[idx] || initVal;
	const _idx = idx;
	const setState = newVal => {
		hooks[_idx] = newVal;
	}
	idx++;
	return [state, setState]
}

React에선 이를 배열과 인덱스를 도입하여 해결합니다. 첫 useState가 실행될 때 고유의 인덱스 값을 부여 받습니다. 인덱스를 통해 hooks 배열에 상태를 저장하고 이후에 useState가 사용될 때마다 hooks[_idx]를 참조하여 상태를 읽습니다. 이러한 원리로 useState는 선언될 때마다 독립된 상태를 가질 수 있습니다.

Hooks 규칙이 생긴 이유

앞서 살펴봤듯이 useState는 각각의 고유한 상태를 hooks 배열에 저장하고 set 함수는 Queue를 통해 상태를 업데이트 합니다. 이 동작 원리를 지키기 위하여 React에선 Hooks 규칙을 도입하였습니다.

최상위에서만 호출해야 합니다

반복문, 조건문 혹은 중첩된 함수 내에서 Hook을 호출할 수 없습니다. 항상 React 함수의 최상위(at the top level)에서 Hook을 호출해야 합니다. useState의 실행이 조건에 따라 다르다면, 컴포넌트가 렌더링 될 때마다 Hook의 실행 순서가 달라질 수 있습니다.

function Counter() {
  function handleClick() {
	  // ❌ 함수 내부에서 선언한다면, hook의 실행 순서가 달라질 수 있습니다.
	  // ❌ handleClick이 실행될 때마다 새로운 count 상태가 생성되고 초기화됩니다.
    const [count, setCount] = useState(0);
    setCount(count + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}
function Counter() {
// ✔️ useState는 항상 컴포넌트의 최상위 레벨에서 호출합니다.
const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

오직 React 함수 내에서 호출해야 합니다

React의 함수 컴포넌트 혹은 커스텀 훅 내부에서 Hook을 호출해야 합니다. React 외부의 JavaScript 함수에서 Hook을 호출한다면 컴포넌트의 렌더링 사이클과 관계없이 호출될 수 있기 때문입니다. 예를 들어, JavaScript 함수가 조건문이나 반복문 내부에 위치할 수 있으며 이 경우 Hook 호출 순서가 렌더링 사이클과 일치하지 않을 수 있습니다.

이 두 가지 규칙을 따르면 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것이 보장됩니다.

⚒️ 코드 레벨 들여다보기

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

앞서 봤던 예시들은 useState의 동작 원리를 알아보기 위한 예시입니다. 실제로 동작하는 useState는 같은 원리지만 렌더링 사이클과 엮여 있어 조금 더 복잡합니다. 위 예시는 실제 코드 레벨의 useState입니다. 함수 내부를 살펴보면 dispatcher를 통해 useState를 반환하고 있습니다. 여기서 dispatcher의 역할을 이해하려면 리액트의 렌더링과 스케줄링 과정을 면밀히 살펴보아야 합니다. Fiber, Reconciler 등의 개념을 파악해야 확실하게 이해할 수 있으므로 이 내용은 다음 게시물에서 다뤄보겠습니다. 지금은 리액트가 실제 돔에 변경 사항을 반영하는 하나의 Task라고 이해합시다.

// react/packages/react/src/ReactHooks.js
function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return ((dispatcher: any): Dispatcher);
}

dispatcher가 갖고 있는 함수를 따라가 봅시다. ReactCurrentDispatcher의 current 값을 반환하고 있습니다.

// react/packages/react/src/ReactCurrentDispatcher.js
/**
 * Keeps track of the current dispatcher.
 */
const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

export default ReactCurrentDispatcher;

ReactCurrentDispatcher까지 따라가 보면 null이 기본값으로 들어 있습니다. 이 값을 사용하려면 다른 함수에서 이 객체에 접근하여 current를 할당해야 합니다.

// react/packages/react-reconciler/src/ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  ...
    ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }

해당 과정은 renderWithHooks 함수로부터 이루어집니다. current가 비어 있거나 캐시된 값이 없다면, HooksDispatcherOnMount를 통해 Hook을 초기화합니다. 혹은 current 값이 존재한다면 HooksDispatcherOnUpdate를 통해 Hook을 업데이트 합니다.

// react/packages/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState, // dispatcher.usestate를 실행하면 동작하는 함수입니다.
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

HooksDispatcherOnMount를 살펴보면 hooks와 관련된 함수들을 객체로 갖고 있습니다. useStatemountState 함수입니다. 앞서 살펴봤던 dispatcher.useStatemountState를 실행하는 것과 동일합니다.

// react/packages/react-reconciler/src/ReactFiberHooks.js
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

mountState에서는 앞서 파악한 hook 배열을 통해 데이터를 담는 원리를 갖고 있습니다. mountStateImpl 함수로부터 받은 hook을 통해 [state, setState] 형태인 [hook.memoizedState, dispatch]로 최종적으로 반환하고 있습니다.

// react/packages/react-reconciler/src/ReactFiberHooks.js
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
	...
  // hook 초기값과 Queue가 할당됩니다.
  // 이 부분이 고유한 인덱스와 같은 역할을 합니다.
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

mountStateImpl을 따라가보면 mountWorkInProgressHook의 함수값을 hook으로 반환하고 있습니다.

// react/packages/react-reconciler/src/ReactFiberHooks.js
let workInProgressHook : Hook | null = null;
...
function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

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

mountWorkInProgressHook 함수에 도착해서야 hook의 최종적으로 연결된 모습이 보입니다. 복잡하게 적혀있는 부분을 제외하고, hook의 모습만 들여다 봅시다.

초기값 Hook Queue

const [state, setState] = useState(0);
const [state2, setState2] = useState("welcome");

이러한 useState를 선언했을 때, Hook은 어떻게 동작하는지 살펴봅시다.

// 0. 선언
const hook: Hook = {
   memoizedState: null,
   baseState: null,
   baseQueue: null,
   queue: null,
   next: null,
}

훅의 초기값은 다음과 같은 객체의 형태를 가집니다. mountStateImpl 함수를 통해 hook.memoizedStatehook.baseState에 초기값을 할당합니다. 그리고 hook.queue에 기본 Queue도 할당합니다.

// 1. 초기값 할당
const hook: Hook = {
   memoizedState: 0,
   baseState: 0,
   baseQueue: null, // hook가 업데이트 될 때 쌓이는 Queue (HooksDispatcherOnUpdate)
   queue: {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  },
   next: null,
}

순차적으로 다음 Hook을 실행한다면 mountWorkInProgressHook 내부에 있는 workInProgressHook 값에 따라 다음 Hook을 hook.next에 할당합니다.

// 2. 다음 Hook 할당
const hook: Hook = {
   memoizedState: 0,
   baseState: 0,
   baseQueue: null,
   queue: {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  },
   next: {
     memoizedState: "welcome",
     baseState: "welcome",
     baseQueue: null,
     queue: {
      pending: null,
      lanes: NoLanes,
      dispatch: null,
      lastRenderedReducer: basicStateReducer, // 이 함수를 통해 상태를 업데이트 합니다.
      lastRenderedState: (initialState: any),
    },
    next: null, 
  },
}

이러한 과정으로 인해 hook.next를 통해 유기적으로 연결되는 구조를 가질 수 있습니다.

업데이트 Hook Queue

const [state, setState] = useState(0);
setState((prev) => prev + 1);
setState((prev) => prev + 3);

이번엔 set 함수를 통해 업데이트했을 때 Hook Queue를 파헤쳐 보겠습니다.

// mountState 함수 중 일부
...
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
...

위에서 자연스럽게 지나갔지만, set 함수는 dispatchSetState 함수 인자에 여러 값을 담아 실행됩니다.

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  ...
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  };
 ...
  try {
    const currentState: S = (queue.lastRenderedState: any); // 렌더링된 최신값인 0이 할당
    const eagerState = lastRenderedReducer(currentState, action);
    update.hasEagerState = true;
    update.eagerState = eagerState;
  }
 ...
}

이 함수를 실행하면 이전에 렌더링된 상태인 queue.lastRenderedStatecurrentState에 할당합니다. 이후 lastRenderedReducer 함수를 통해 update 객체에 eagerState라는 새로운 값을 할당합니다. 앞서 살펴본 hook queue에선 lastRenderedReducerbasicStateReducer의 함수를 할당하고 있습니다.

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types
  return typeof action === 'function' ? action(state) : action;
}

이 함수를 통해 set 함수의 원리를 파악할 수 있습니다. action은 set 함수 내부에 기입한 값입니다. 이 값이 함수 형태라면 action 함수 인자에 기존 상태(state)를 넣어 실행한 값을, 그렇지 않다면 action 자체를 반환하도록 합니다. 이러한 과정 덕분에 set 함수 내부에서 이전 상태를 인자로 받을 수 있게 됩니다.

여러 set 함수가 존재한다면 앞서 살펴봤던 hook queue와 같이 update.next안에 유기적으로 연결되어 형성됩니다. 만들어진 update 객체는 hook의 basicQueue 안에 담겨집니다.

// 2. set 함수 실행
const update: Update<S, A> = {
   lane,
   revertLane: NoLane,
   action, // (prev) => prev + 1
   hasEagerState: true,
   eagerState: 1,
   next: {
     lane,
     revertLane: NoLane,
     action, // (prev) => prev + 3
     hasEagerState: true,
     eagerState: 4,
     next : null, // set 함수가 추가된다면 이 곳에 값이 들어갑니다.
   },
}
...
 try {
    const currentState: S = 0;
    const eagerState = lastRenderedReducer(currentState, action);
    update.hasEagerState = true;
    update.eagerState = eagerState;
  }
...

💡 결론

useState 함수의 기본 동작과 코드 레벨에서의 작동 원리를 자세히 살펴보았습니다. 물론 수박 겉핥기식으로 넘어간 부분이 많습니다. 리액트 렌더링 과정을 더 깊게 파헤쳐야 완전한 이해를 할 수 있죠. 코드 레벨을 들여다 봤을 때 핵심은 클로저를 어떻게 사용하고 있는가입니다.

useState의 상태값은 컴포넌트 외부에 있는 hook이라는 배열 형태의 객체에 저장됩니다. 이 때 useState가 생성됐을 때 환경을 기억하여(클로저) useState마다 독립적으로 값을 참조할 수 있도록 내부에서 고유의 값을 기억합니다. hook는 배열 형태로 저장되기 때문에 hook 규칙을 지키지 않으면 잘못된 배열 순서의 값을 참조할 수 있습니다.

개인적으로 useState에 관해 다음 3가지를 기억하면 좋겠습니다. 😁

  1. set 함수는 비동기로 동작한다.
  2. 상태 변경을 알기 쉽도록 상태의 불변성을 유지해야 한다 (Primitive Value)
  3. 모든 이벤트 핸들러가 종료되면 Queue를 실행한다

다음엔 리액트의 렌더링 과정과 스케줄링에 대해 조금 더 파헤쳐 보겠습니다.

참고

useState – React

React v18.0 – React

Hook의 규칙 – React (reactjs.org)

Queueing a Series of State Updates – React

State as a Snapshot – React

facebook/react: The library for web and native user interfaces. (github.com)

javascript - Does React keep the order for state updates? - Stack Overflow

Can Swyx recreate React Hooks and useState in under 30 min? - JSConf.Asia (youtube.com)

Automatic batching for fewer renders in React 18 · reactwg/react-18 · Discussion #21 (github.com)

리액트를 까보자. 해당 포스팅은 공유용이 아닌, 의식의 흐름대로 내용을 정리한 것이라… | by valley | Medium

React Deep Dive — Fiber 선언형 UI 라이브러리의 동시성 렌더링 기술 | 콴다 팀블로그 (mathpresso.com)

진짜 리액트는 어떻게 생겼나? (1) - useState 따라가며 흐름잡기 — _0422의 생각 (tistory.com)

#useState#클로저#React 상태 관리#React 18#flushSync#불변성#useState Closure#Hooks