July 13, 20256 minutes

import { createRoot } from "react-dom/client";
export const App = () => {
  return (
    <div>
      <button>A : 1</button>
      <button>B : 2</button>
    </div>
  );
};
createRoot(document.getElementById("root")).render(<App />);이제 useState를 직접 만들어 보겠습니다.useState는 초기값을 받아 상태와 상태 변경 함수를 반환하는 함수입니다.
가장 먼저 떠올릴 수 있는 단순한 형태는 이렇습니다.
const useState = (initialValue) => {
  const setValue = (newValue) => {
    console.log(newValue);
  };
  return [initialValue, setValue];
};이 함수를 A 버튼에 적용해 보겠습니다.
(B 버튼은 잠시 잊어주십시오.)
export const App = () => {
  const [countA, setCountA] = useState(1);
  return (
    <div>
      <button>A : {countA} </button>
      <button>B : 2</button>
    </div>
  );
};하지만 이 코드에는 명백한 문제가 있습니다.useState는 항상 initialValue만 반환하기 때문에, 나중에 상태를 변경하더라도 화면에는 항상 초기값인 1만 표시될 것입니다.
우리는 초기값이 아닌, ‘가장 최신의 상태값’을 기억하고 반환해야 합니다.
이를 위해, 상태값을 저장할 별도의 변수가 필요합니다.
만약 이 변수를 useState 함수 내부에 선언한다면, 함수가 호출될 때마다 변수가 초기화되어 상태를 기억할 수 없게 됩니다.
따라서 우리는 함수가 여러 번 호출되어도 값이 유지되도록, 즉 ‘상태를 유지(persist)‘하도록 useState 함수 바깥에 변수를 선언해야 합니다.
let stateValue;
const useState = (initialValue) => {
  if (stateValue === undefined) {
    stateValue = initialValue;
  }
  const setValue = (newValue) => {
    stateValue = newValue;
  };
  return [stateValue, setValue];
};이제 stateValue가 아직 정의되지 않았을 때(최초 호출 시)만 initialValue를 할당하고, 그 이후에는 저장된 stateValue를 계속 사용합니다.
하지만 버튼을 클릭해도 여전히 화면은 바뀌지 않습니다.stateValue 값은 실제로 변경되지만, 리액트가 이 변화를 인지하고 화면을 다시 그리지(re-render) 않기 때문입니다.
상태가 변경될 때마다 화면을 다시 그리도록 ‘강제’해야 합니다.
이를 위해 react-dom의 render 메서드를 사용하는 우리만의 렌더링 함수를 만들어 보겠습니다.
let root;
const render = () => {
  if (!root) {
    root = createRoot(document.getElementById("root"));
  }
  root.render(<App />);
};
// 최초 렌더링 실행
render();이제 상태 변경 함수인 setValue가 호출될 때마다, 이 render 함수를 실행해주면 됩니다.
const useState = (initialValue) => {
  if (stateValue === undefined) {
    stateValue = initialValue;
  }
  const setValue = (newValue) => {
    stateValue = newValue;
    render(); // 상태 변경 후 리렌더링!
  };
  return [stateValue, setValue];
};이제 버튼을 클릭하면 setCountA가 호출되고, stateValue가 업데이트된 후 render 함수가 실행되어 화면이 다시 그려지면서 최신 countA 값이 보이게 됩니다.
export const App = () => {
  const [countA, setCountA] = useState(1);
  const [countB, setCountB] = useState(2);
  return (
    <div>
      <button onClick={() => setCountA(countA + 1)}>A : {countA}</button>
      <button onClick={() => setCountB(countB + 1)}>B : {countB}</button>
    </div>
  );
};이 코드는 제대로 작동하지 않습니다.
우리의 useState는 오직 하나의 stateValue 변수만을 사용하기 때문에, 두 번째 useState 호출은 첫 번째 호출이 저장한 값을 그대로 덮어쓰거나 공유하게 됩니다.
하지만 실제 리액트에서는 각 useState가 완벽하게 독립적인 상태를 가집니다.
이를 어떻게 구현할 수 있을까요.
정답은 바로 ‘배열’을 사용하는 것입니다.
하나의 변수 대신, 상태 값들을 저장할 배열을 만듭니다.
그리고 각 useState 호출의 순서를 기억할 ‘인덱스’를 사용합니다.
컴포넌트가 렌더링될 때마다 useState는 항상 정해진 순서대로 호출된다는 사실을 이용하는 것입니다.
let root;
let stateValues = [];
let callIndex = -1;
const render = () => {
  if (!root) {
    root = createRoot(document.getElementById("root"));
  }
  callIndex = -1; // 리렌더링 될 때마다 인덱스를 초기화
  root.render(<App />);
};
const useState = (initialValue) => {
  callIndex++; // useState 호출 시마다 인덱스 증가
  if (stateValues[callIndex] === undefined) {
    stateValues[callIndex] = initialValue;
  }
  const setValue = (newValue) => {
    stateValues[callIndex] = newValue;
    render();
  };
  return [stateValues[callIndex], setValue];
};
render();이제 첫 번째 useState가 호출되면 callIndex는 0이 되고, stateValues[0]에 상태가 저장됩니다.
두 번째 useState가 호출되면 callIndex는 1이 되고, stateValues[1]에 상태가 저장됩니다.
리렌더링이 발생하면 render 함수에서 callIndex를 다시 -1로 초기화하기 때문에, 다음 렌더링 사이클에서도 useState는 순서대로 0번, 1번 인덱스에 정확하게 접근할 수 있습니다.
  const setValue = (newValue) => {
    stateValues[callIndex] = newValue;
    render();
  };이 함수가 상태를 변경할 때 사용하는 callIndex는, useState 함수가 모두 실행된 후의 ‘최종값’입니다.
우리 예제에서는 1이겠죠.
따라서 setCountA를 호출하든, setCountB를 호출하든 항상 stateValues[1]의 값만 변경하게 됩니다.setCountA는 0번 인덱스를, setCountB는 1번 인덱스를 정확히 기억하고 찾아가야 합니다.
바로 이 문제를 해결하는 열쇠가 자바스크립트의 ‘클로저(Closure)‘입니다.
클로저는 ‘함수가 자신이 태어난 환경(스코프)을 기억하는 것’을 의미합니다.
함수가 선언될 때의 주변 변수들을 마치 ‘기억의 가방’처럼 가지고 다니다가, 나중에 어디서 호출되든 그 기억 속 변수들을 사용할 수 있는 능력입니다.
이 원리를 이용해 코드를 수정해 보겠습니다.
const useState = (initialValue) => {
  callIndex++;
  const currentIndex = callIndex; // 호출 시점의 인덱스를 '가방'에 담을 준비
  if (stateValues[currentIndex] === undefined) {
    stateValues[currentIndex] = initialValue;
  }
  // setValue 함수는 선언될 때의 'currentIndex' 값을 '기억'합니다.
  const setValue = (newValue) => {
    stateValues[currentIndex] = newValue;
    render();
  };
  return [stateValues[currentIndex], setValue];
};이제 useState가 처음 호출될 때 생성된 setValue(즉, setCountA)는 currentIndex가 0이었던 순간을 영원히 기억합니다.
두 번째 호출로 생성된 setValue(즉, setCountB)는 currentIndex가 1이었던 순간을 기억합니다.
덕분에 각 상태 변경 함수는 자신만의 고유한 인덱스를 정확히 찾아갈 수 있게 됩니다.
바로 이 지점에서 우리는 ‘훅의 규칙’이 왜 필요한지 명확하게 이해할 수 있습니다.
첫 번째 규칙, ‘훅은 최상위 레벨에서만 호출되어야 한다’.
만약 훅을 조건문 안에서 호출하면 어떻게 될까요.
// 절대 이렇게 사용하면 안 됩니다!
export const App = () => {
  const [countA, setCountA] = useState(1);
  let countB, setCountB;
  if (countA < 3) {
    [countB, setCountB] = useState(2);
  }
  // ...
};countA가 3보다 작을 때는 useState가 두 번 호출되어 callIndex가 0, 1로 증가합니다.
하지만 countA가 3이 되는 순간, if문이 실행되지 않아 useState는 한 번만 호출되고 callIndex는 0까지만 증가합니다.
다음 렌더링부터는 useState 호출 순서가 완전히 엉망이 되어, 리액트는 어떤 상태가 어떤 useState에 해당하는지 전혀 알 수 없게 됩니다.
이것이 바로 리액트가 훅의 ‘호출 순서’에 의존하기 때문에, 조건문이나 반복문 안에서 훅을 호출하는 것을 금지하는 이유입니다.