Vanilla JS로 React Hook 구현하기 - useState

q1q1·2026년 6월 16일

Mini React

목록 보기
1/6

React를 사용하다보면 제일 먼저 접하고 자주 사용하게 되는 것이 useState다.

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

코드 자체는 익숙하지만, 자세히 들여다보면 신기한 구조로 되어있다.
함수 컴포넌트는 일반 JavaScript 함수처럼 실행되고 끝나는 구조인데 어떻게 다음 렌더링에서도 이전 count 값을 기억할 수 있을까?

일반적인 함수라면 함수 실행이 끝난 뒤 지역 변수는 사라진다. 그런데 React의 useState는 컴포넌트가 다시 실행되어도 이전 상태를 유지한다.

이번 글에서는 React의 useState가 어떤 문제를 해결하는지 먼저 정리하고, 그 원리를 Vanilla JS로 간단히 구현해보려 한다.

useState는 무엇을 해주는걸까?

useState는 함수 컴포넌트에서 상태를 사용할 수 있게 해주는 Hook이다.

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

useState는 두가지 값을 반환한다.

  1. state : 현재 상태값
  2. setState : 상태를 변경하고 컴포넌트를 다시 렌더링하게 만드는 함수

예를 들어보자면,

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

setCount(count + 1);

이 코드가 실행되면 React는 상태를 업데이트하고 컴포넌트를 다시 렌더링한다.

즉, useState의 핵심 역할은 다음과 같다.

1. 상태값을 기억한다.
2. 상태 변경 함수를 제공한다.
3. 상태가 바뀌면 컴포넌트를 다시 렌더링한다.

상태값을 기억한다면 그냥 변수로는 안 될까?

처음에는 이렇게 생각할 수 있다.

function Counter() {
  let count = 0;

  function increment() {
    count++;
  }

  return <button onClick={increment}>{count}</button>;
}

하지만 이 방식은 React의 렌더링 구조에서 상태를 유지할 수 없다. 함수 컴포넌트는 렌더링될 때마다 다시 실행되기 때문이다.

렌더링
  -> Counter 함수 실행
  -> count = 0 생성

상태 변경 후 리렌더링
  -> Counter 함수 다시 실행
  -> count = 0 다시 생성

즉 함수 내부의 지역 변수는 렌더링 사이에 값을 기억하지 못한다.
그래서 React는 컴포넌트 함수 바깥의 내부 저장소에 상태를 보관하고, 컴포넌트가 다시 실행될 때 해당 상태를 다시 꺼내준다.

이 원리를 아~~~주 단순화하면 다음과 같다.

컴포넌트 함수 내부
  const [count, setCount] = useState(0);

React 내부
  count 변수 자체가 아니라 state 값을 따로 저장해둠
  다음 렌더에서 useState 호출 순서에 맞춰 저장된 state를 다시 꺼내줌

이번 글에서 구현할 미니 useState도 바로 이 구조로..! 해본다!!💪💪💪💪💪

구현목표

이번에 만들 미니 useState는 실제 React와 완전히 같지는 않을 것이다ㅜ 대신 핵심 원리를 이해하는데 집중하고자 했다.
구현 목표는 다음과 같다.

  1. 기본 목표
    1. render / rerender 흐름 익히기
    2. useState로 상태를 저장하고 반환하기
    3. 여러 개의 useState를 호출할 수 있게 만들기
    4. setState가 상태 변경 후 rerender를 일으키게 만들기
  2. 최적화 (추가로 다룬 내용)
    1. 함수형 업데이트 지원하기
    2. state가 같은 값이면 rerender 생략하기
    3. lazy initializer 지원하기
    4. batching으로 여러 setState를 한 번의 render로 묶기

최적화 파트는 구현해가면서 보완하면 좋을 것 같은 부분을 실제로 구현하면서 추가했다!


(요런 간단한 느낌~)

구현 가보자고!

기본 세팅: render / rerender 흐름 만들기

useState를 바로 구현하기 전에 먼저 renderrerender 흐름부터 단순하게 만들어보았다.
우선 render가 되는 구조를 만들어야, useState를 구현할 수 있겠다 싶어서 구현했다.
React에서 상태가 변경되면 컴포넌트는 다시 렌더링된다. 이 흐름을 아주 단순화하면 다음과 같다.

상태 변경
  -> 컴포넌트 함수 다시 실행
  -> 새로운 UI 결과 생성
  -> DOM에 반영

이번 미니 구현에서는 JSX나 Virtual DOM은 사용하지 않았다. 대신 App 함수가 HTML 문자열을 반환하고, render 함수가 그 문자열을 실제 DOM에 넣도록 했다.

const root = document.querySelector("#app");

function App() {
  return `
    <main>
      <h1>Mini React Hooks</h1>
      <p>count: 0</p>
      <button type="button">Increment</button>
    </main>
  `;
}

function render() {
  root.innerHTML = App();
}

render();

function App()의 경우 화면에 그릴 UI 결과를 만들고, function render()는 App을 실행하고 그 결과를 실제 DOM에 반영하도록 역할을 나누었다.

처음 render()를 호출하면 초기 렌더링이 된다. 이후 상태가 바뀐 뒤 다시 render()를 호출하면 리렌더링이 된다.

useState 기본 목표 충족하기

앞서 구현한 render의 흐름으로 자연스럽게 기본 목표를 충족해본다면 다음과 같은 흐름이다.

render()
-> App() 실행
-> App 안에서 useState 호출
-> 저장된 state 반환
-> setState 호출 시 state 변경
-> render() 다시 실행

이걸 이제 실제로 구현해보자!

let states = [];
let hookIndex = 0;

// 렌더 전에 hookIndex를 초기화하기 위해 정의
export function resetHookIndex() {
  hookIndex = 0;
}

export function useState(initialState) {
  const currentIndex = hookIndex;

  if (states[currentIndex] === undefined) {
    // 현재 states[currentIndex]값이 없다면 값을 넣어준다.
    states[currentIndex] = initialState;
  }

  function setState(newState) {
    states[currentIndex] = newState;
    rerender();
  }

  hookIndex++;
  return [states[currentIndex], setState];
}

핵심은 두 가지!

  • states 배열 : 컴포넌트 함수 바깥에 존재하는 저장소다. 컴포넌트가 다시 실행되어도 이 배열은 사라지지 않기 때문에 이전 상태를 기억할 수 있다.
  • hookIndex : useState가 호출될 때마다 어떤 슬롯을 쓸지 결정하는 인덱스다. 컴포넌트 함수는 매 렌더마다 다시 실행되므로, 렌더가 시작될 때 resetHookIndex()로 인덱스를 0으로 초기화해줘야 한다. 덕분에 useState를 여러 번 호출해도 각각 독립적인 슬롯을 갖게 된다.
const [count, setCount] = useState(0);  // hookIndex 0
const [step, setStep]   = useState(1);  // hookIndex 1
render() 호출
  -> resetHookIndex()  // hookIndex = 0
  -> App() 실행
    -> useState(0)  // hookIndex=0 슬롯 사용, hookIndex++ → 1
    -> useState(1)  // hookIndex=1 슬롯 사용, hookIndex++ → 2
  -> DOM 업데이트

setCount(1) 호출
  -> states[0] = 1
  -> rerender()

render() 다시 호출
  -> resetHookIndex()  // hookIndex = 0
  -> App() 다시 실행
    -> useState(0)  // hookIndex=0, states[0]은 이미 1 → 1 반환
    ...

이 구조 덕분에 컴포넌트가 몇 번을 다시 실행되더라도 useState 호출 순서가 고정되어 있으면 각 슬롯에 올바른 상태가 매핑된다. React가 Hook을 조건문이나 반복문 안에서 호출하지 말라고 하는 이유가 바로 이것이다. 순서가 흔들리면 전혀 다른 슬롯의 값을 꺼내오게 된다.

잠깐, setState는 어떻게 자신의 슬롯을 기억할까?
앞서 구현한 코드를 다시 보면 이상한 부분이 하나 있다.

export function useState(initialState) {
  const currentIndex = hookIndex; // ← 왜 hookIndex를 바로 쓰지 않고 따로 뽑아둘까?

  function setState(newState) {
    const currentState = states[currentIndex];
    // ...
  }

  hookIndex++;
  return [states[currentIndex], setState];
}

이걸 이해하려면 클로저를 알아야 한다.

클로저 란?
클로저는 함수가 자신이 선언된 시점의 외부 변수를 기억하는 것 이다. 중요한 건, 값을 복사해서 기억하는 게 아니라 변수 자체를 참조한다는 점이다.

hookIndex를 직접 참조하면 어떻게 될까?

function setState(newState) {
  states[hookIndex] = newState; // hookIndex를 직접 참조
}

App 안에서 useState를 두 번 호출하는 경우를 따라가보자.

useState(0)   호출 — hookIndex = 0, setState_A 생성, hookIndex++ → 1
useState(100) 호출 — hookIndex = 1, setState_B 생성, hookIndex++ → 2

이후 setState_A 호출
  → states[hookIndex] 업데이트
  → 이때 hookIndex는 2 ← 엉뚱한 슬롯!

두 setState가 같은 hookIndex 변수를 참조하고 있기 때문에, 렌더가 끝나 hookIndex가 바뀐 시점에 호출하면 엉뚱한 슬롯을 건드리게 된다.

const currentIndex = hookIndex로 현재 값을 새 변수에 담아두면, 이후 hookIndex가 아무리 변해도 setState는 자신이 만들어진 시점의 currentIndex를 클로저로 기억한다.

useState(0)   호출 — currentIndex = 0, setState_A가 0을 클로저로 기억
useState(100) 호출 — currentIndex = 1, setState_B가 1을 클로저로 기억
이후 setState_A 호출 → states[0] 업데이트 ✓
이후 setState_B 호출 → states[1] 업데이트 ✓

useState의 구현 자체가 클로저 없이는 동작할 수 없는 구조인 것 같다.

useState 최적화 해보기

1. 함수형 업데이트 지원하기

기본 구현에서는 setState(count + 1) 처럼 값을 직접 넘긴다. 그런데 이 방식에는 문제가 있다.

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

count가 0이면 세 번 모두 setState(1)을 호출하는 셈이 되어 결과가 1이 된다. 각 호출이 현재 렌더의 count 값을 캡처하기 때문이다.

이를 해결하는 것이 함수형 업데이트다.

setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);

setState에 함수를 넘기면, 현재 렌더의 값이 아니라 states 배열에 저장된 최신 값을 기준으로 업데이트할 수 있다.

처음엔 헷갈렸는데, setState 안에서 넘겨받은 값이 함수인지 확인하고, 함수면 현재 저장된 상태를 인자로 실행하면 된다.

function setState(newState) {
  const currentState = states[currentIndex];
  // newState가 함수일 경우 함수 실행한 값을 nextState에 넣기
  const nextState = typeof newState === "function" ? newState(currentState) : newState;

  states[currentIndex] = nextState;
  rerender();
}

이제 setCount((prev) => prev + 1)을 세 번 연속 호출하면 states[0]이 순차적으로 갱신되어 최종 결과가 3이 된다.

2. 같은 값이면 rerender 생략하기

현재 구현은 setState를 호출하면 무조건 rerender()를 실행한다. 그런데 이전 값과 새 값이 같다면 굳이 다시 그릴 필요가 없다.

function setState(newState) {
  const currentState = states[currentIndex];
  const nextState = typeof newState === "function" ? newState(currentState) : newState;

  // 조건문 추가: 현재 state값과 nextState값을 비교해서 같다면 render없이 return
  if (Object.is(currentState, nextState)) {
    return;
  }

  states[currentIndex] = nextState;
  rerender();
}

Object.is를 쓰는 이유는 ===과 거의 같지만 두 가지 엣지 케이스를 더 정확하게 처리하기 때문이다.

Object.is(NaN, NaN)  // true  (=== 는 false)
Object.is(+0, -0)    // false (=== 는 true)

3. Lazy Initializer

useState의 초기값으로 계산 비용이 큰 값을 넣어야 할 때가 있다.

// 매 렌더마다 heavyCalculation()이 실행된다
const [value, setValue] = useState(heavyCalculation());

heavyCalculation()은 초기 렌더에서만 필요한데, 위 코드는 렌더마다 실행된다. 함수 인자는 함수가 호출되기 전에 먼저 평가되기 때문이다.
이를 해결하는 것이 lazy initializer다. 초기값 대신 초기값을 반환하는 함수를 넘기면, 처음 슬롯이 비어있을 때 한 번만 실행한다. 이 부분은 initialValue 부분을 넣는 과정에서 조건문을 추가했다.

// state가 비어있는 경우
if (states[currentIndex] === undefined) {
		// initialState가 함수인 경우 함수 실행한 값 넣기
		const initialValue = typeof initialState === "function" ? initialState() : initialState;

		states[currentIndex] = initialValue;
	}

슬롯이 비어있을 때만 초기값 함수를 실행하므로, 이후 렌더에서는 호출되지 않는다.

4. Batching

현재 구현에서 setState를 여러 번 연속 호출하면 rerender()도 그 횟수만큼 실행된다. 함수형 업데이트 덕분에 상태 값 자체는 올바르게 누적되지만, 렌더링은 불필요하게 여러 번 일어난다.

// 이 경우 rerender가 3번 실행된다
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);

이를 해결하는 것이 batching이다. 여러 setState 호출을 하나의 렌더로 묶는다.
핵심 아이디어는 rerender()를 지금 당장 실행하지 않고, 현재 실행 중인 코드가 모두 끝난 직후로 예약하는 것이다. 이미 예약이 걸려있으면 추가로 예약하지 않는다.

let isRenderScheduled = false;

// batching을 위한 스케쥴 함수 구현
export function scheduleRerender() {
	if (isRenderScheduled) {
		return;
	}

	isRenderScheduled = true;

	// render를 지금 당장 실행하지 않고, 현재 코드가 끝난 직후 실행하도록 예약하기
	queueMicrotask(() => {
		isRenderScheduled = false;
		rerender();
	});
}

queueMicrotask는 현재 실행 컨텍스트가 끝나고 브라우저가 다음 태스크를 처리하기 직전에 콜백을 실행한다. 덕분에 같은 이벤트 핸들러 안에서 setState를 세 번 호출해도 렌더는 한 번만 일어난다.

클릭 이벤트 핸들러 실행
  -> setCount((prev) => prev + 1)  // states[0] = 1, 렌더 예약
  -> setCount((prev) => prev + 1)  // states[0] = 2, 이미 예약됨 → 스킵
  -> setCount((prev) => prev + 1)  // states[0] = 3, 이미 예약됨 → 스킵
핸들러 종료
  -> microtask 실행 → rerender() 1회

마무리

아주 단순한 미니 버전의 구현이지만, 실제 React의 useState가 해결하는 문제들을 직접 마주칠 수 있었다. 그 부분은 특히 최적화 파트에서 많이 느꼈는데, 호출 순서와 batching파트에서 더 느꼈던 것 같다.

물론 실제 React는 훨씬 복잡하겠지..😇? Fiber 스케줄러, 우선순위 기반 렌더링, concurrent 모드 등 이번 구현에서 다루지 못한 부분이 많다. 그래도 "왜 이렇게 동작하는가"를 이해하는 출발점으로는 충분한 구현이었다!!!! 👏👏👏👏👏

profile
끄적끄적

0개의 댓글