프론트엔드/React

[React] useState의 동작 원리와 클로저

useState hook은 함수형 컴포넌트의 상태 관리 문제를 클로저를 통해 해결합니다.

 

이 글을 통해 간단한 useState hook을 단계별로 직접 구현해보겠습니다.

매 단계마다 어떠한 문제를 만나 어떻게 해결하는지의 과정을 이해해봅시다.

 

(아래의 글들이 useState를 이해하는데 많은 도움이 되었습니다.)

 


함수형 컴포넌트의 상태 관리

클래스형 컴포넌트는 render() 메서드를 통해 상태 변경을 감지할 수 있습니다.

반면 함수형 컴포넌트는 렌더링이 발생하면 함수 자체가 다시 호출됩니다.

그래서 상태를 관리하려면 함수가 다시 호출되었을 때 이전 상태를 기억하고 있어야 합니다.

 

useState는 이 문제를 클로저를 통해 해결합니다.

클로저는 간단히 말해 내부 함수에서 상위 함수 스코프의 변수에 접근할 수 있는 개념입니다.

(자세한 개념은 따로 포스팅으로 정리했으니 참고 바랍니다.)

 

클로저를 통해 어떻게 문제를 해결했는지 직접 useState를 구현하며 알아보겠습니다.

 


useState 구현하기

1️⃣ 첫 번째 코드

const useState = (initialValue) => {
  let value = initialValue;
  
  const state = () => value;

  const setState = (newValue) => {
    value = newValue;
  };
  
  return [state, setState];
};


const [counter, setCounter] = useState(0);

console.log(counter()); // 0
setCounter(1);
console.log(counter()); // 1

클로저 개념을 통해 간단하게 구현한 useState 함수입니다.

[state, setState]가 선언되는 시점에서 useState의 호출은 끝나게 됩니다.

이때 클로저가 내부의 value 값을 기억하고 있기 때문에 이후에도 접근이 가능합니다.

 

🎯 문제점

다만 state가 getter 방식의 함수로 구현됐습니다.

실제 useState와 동일하게 만들려면 state를 함수가 아닌 변수로 선언해야 합니다.

 


2️⃣ 두 번째 코드

const useState = (initialValue) => {
  let state = initialValue;

  const setState = (newValue) => {
    state = newValue;
  };
  
  return [state, setState];
};


const [counter, setCounter] = useState(0);

console.log(counter); // 0
setCounter(1);
console.log(counter); // 0 → Error!

state를 함수가 아닌 변수로 선언하여 코드를 개선했습니다.

하지만 setState는 의도한 대로 동작하지 않습니다.

 

🎯 문제점

state는 변수이기 때문에 useState로 리턴된 순간 더이상 변경할 수 없는 상태가 됩니다.

 

우리는 state를 변수로 표현하면서도 상태 값을 유지하도록 만들어야 합니다.

리액트는 state를 useState의 외부에 선언함으로써 이 문제를 해결합니다.

 


3️⃣ 세 번째 코드

const MyReact = (function () {
  let state;

  return {
    render(Component) {
      const Comp = Component();
      Comp.render();
      return Comp;
    },

    useState(initialValue) {
      state ||= initialValue;

      const setState = (newValue) => {
        state = newValue;
      };

      return [state, setState];
    },
  };
})();

const Counter = () => {
  const [count, setCount] = MyReact.useState(0);

  return {
    click: () => setCount(count + 1),
    render: () => console.log('render:', { count }),
  };
};

let App;
App = MyReact.render(Counter); // render: { count: 0 }
App.click();
App = MyReact.render(Counter); // render: { count: 1 }

myReact는 React 모듈, Counter는 컴포넌트의 단순 구현체로 보겠습니다.

||= (Logical OR assignment)를 통해 state가 undefined일 때만 initailValue로 초기화합니다.

 

state는 useState의 외부 스코프에 선언되었습니다.

setState가 실행되면 외부 스코프의 state를 변경하게 됩니다.

그리고 클로저의 원리에 따라 state 값은 사라지지 않습니다.

그래서 컴포넌트가 리렌더 되어 useState가 새로 실행되어도 state 값을 유지할 수 있게 됩니다.

 

 

🎯 문제점

위의 코드는 잘 실행될 것입니다.

그러나 useState를 사용하는 컴포넌트가 여러 개라면 문제가 생깁니다.

하나의 state 변수에 여러 컴포넌트가 접근하기 때문에 모든 컴포넌트의 state가 동일해지기 때문입니다.

 

리액트는 state를 useState 외부에 배열 형식으로 관리하여 이 문제를 해결합니다.

 


4️⃣ 완성된 코드

let state = [];
let setters = [];
let cursor = 0;
let firstrun = true;

const createSetter = (cursor) => {
  return (newValue) => {
    state[cursor] = newValue;
  };
};

const useState = (initialValue) => {
  if (firstrun) {
    state.push(initialValue);
    setters.push(createSetter(cursor));
    firstrun = false;
  }

  const resState = state[cursor];
  const resSetter = setters[cursor];
  cursor++;

  return [resState, resSetter];
};

useState 외부에 선언된 state를 배열 형식으로 변경했습니다.

useState로 선언된 state들은 배열에 순서대로 저장됩니다.

이러한 state 배열은 컴포넌트를 유일하게 구분 짓는 키를 통해 접근할 수 있습니다.

 

다만 리액트는 hook을 안전하게 사용하기 위해 몇 가지 규칙들을 정의했습니다.

 


🎯 Hook 규칙

공식 문서를 참고했습니다.

 

💡 최상위(at the Top Level)에서만 hook을 호출해야 합니다.

반복문, 조건문 혹은 중첩 함수에서 hook을 호출하면 안 됩니다.

state는 컴포넌트의 실행 순서대로 배열에 저장될 것입니다.

만약 조건문 등을 만나면 컴포넌트의 실행 순서가 달라질 수 있습니다.

 

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

Hook을 일반적인 JavaScript 함수에서 호출하면 안 됩니다.

함수 컴포넌트, 커스텀 훅 내에서만 호출할 수 있습니다.

 

두 규칙을 따랐을 때 컴포넌트가 렌더링 될 때마다 동일한 순서로 hook이 호출되는 것을 보장합니다.

 


useState 모듈 분석

// ReactHooks.js

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

리액트 모듈에서 찾아본 useState의 초기 상태는 다음과 같습니다.

 

resolveDispatcher의 리턴 값을 dispatcher에 할당합니다.

이후 dispatcher의 useState 메서드에 initialState를 인자로 전달합니다.

 

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

  if (__DEV__) {
    if (dispatcher === null) {
      console.error('Some error msg...');
    }
  }

  return ((dispatcher: any): Dispatcher);
}

resolveDispatcher 함수는 ReactCurrentDispatcher의 current 값을 할당받습니다.

 

const ReactCurrentDispatcher = {
  /**
   * @internal
   * @type {ReactComponent}
   */
  current: (null: null | Dispatcher),
};

ReactCurrentDispatcher.current는 전역에 선언된 객체의 프로퍼티입니다.

(더 자세히 알아보기엔 내용이 깊어져 실력이 쌓이면 다시 보강하겠습니다.)

 

핵심은 useState의 리턴 값의 출처가 전역에서 온다는 점입니다.

리액트가 실제로 클로저를 활용해 함수 외부의 값에 접근하는 사실을 알 수 있습니다.

 


useState와 함수형 인자

const Counter = () => {
  const [count, setCount] = useState(0);

  const increase1 = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  const increase2 = () => {
    setCount((count) => count + 1);
    setCount((count) => count + 1);
    setCount((count) => count + 1);
  }
}

export default Counter;

리액트를 배우다 보면 한 번쯤 만날 수 있는 예제입니다.

위의 예제에서 increase1 함수의 결과는 3이 아닌 1입니다.

반면 increase2 함수의 결과는 의도한 대로 3이 됩니다.

 

리액트는 setState의 인자가 변수인가 함수인가의 차이를 이렇게 정리합니다.

 

If the new state is computed using the previous state, you can pass a function to setState.

: 새로운 상태가 바로 이전 상태를 통해 계산되어야 하면 함수를 써야 합니다.

 

React may batch multiple setState() calls into a single update for performance.

During subsequent re-renders, the first value returned by useState will always be the most recent state after applying updates.

: 리액트는 퍼포먼스 향상을 위해 특별한 배치 프로세스를 사용하기 때문입니다.

여러 setState 업데이트를 한 번에 묶어서 처리한 후 마지막 값을 통해 state를 결정하는 방식입니다.

 

useState의 내부 구조를 보면 왜 함수형 인자가 값을 실시간으로 업데이트를 하는지 알 수 있습니다. 

 

 

useState의 내부 구조

{
  memoizedState: 0, // first hook
  baseState: 0,
  queue: { /* ... */ },
  baseUpdate: null,
  next: { // second hook
    memoizedState: false,
    baseState: false,
    queue: { /* ... */ },
    baseUpdate: null,
    next: { // third hook
      memoizedState: {
        tag: 192,
        create: () => {},
        destory: undefined,
        deps: [0, false],
        next: { /* ... */ }
      },
      baseState: null,
      queue: null,
      baseUpdate: null,
      next: null
    }
  }
}

위 코드는 실제 hook을 변수에 할당하여 출력했을 때 나타나는 결과입니다.

next는 연결 리스트의 일종으로, 한 컴포넌트 안에서 여러 번의 실행되는 hook들을 연결해주는 역할을 합니다.

 

{
  memoizedState: 0,
  baseState: 0,
  queue: {
   last: {
      expirationTime: 1073741823,
      suspenseConfig: null,
      action: 1, // setCount를 통해 설정한 값
      eagerReducer: basicStateReducer(state, action),
      eagerState: 1, // 상태 업데이트를 마치고 실제 렌더링되는 값
      next: { /* ... */ },
      priority: 98
    },
    dispatch: dispatchAction.bind(bull, currenctlyRenderingFiber$1, queue),
    lastRenderedReducer: basicStateReducer(state, action),
    lastRenderedState: 0,
  },
  baseUpdate: null,
  next: null
}

리액트의 배치 프로세스는 이렇게 묶인 hook들을 한 번에 처리한 뒤 last를 생성합니다.

여기서 주목할 부분은 최종 반환될 상태인 eagerState를 계산하는 함수가 Reducer라는 것입니다.

 

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

이 Reducer에 넘기는 action 타입이 함수일 때 이전 상태를 인자로 받습니다.

그래서 기존 상태를 기반으로 새로운 상태를 업데이트할 수 있게 됩니다.

 


당근마켓 면접을 보며 앞으로의 학습은 로부터 시작하고 싶었습니다.

이 글에 그러한 생각을 드러내기 위해 많은 참고 자료와 많은 시간을 들인 것 같습니다.

 

다만 아직 부족한 실력 탓에 개념을 잘못 이해한 부분이 있을까봐 걱정입니다.

그래서 꾸준히 이 글을 돌아보며 보완하도록 하겠습니다!