정답: agename의 값의 위치가 교환된다.

age: 20

name: John

훅의 규칙: 반복문, 조건문, 또는 중첩된 함수 안에서 훅을 호출해서는 안된다.


직접 useState를 구현하면서 위의 규칙이 무엇을 의미하는지 알아보자.

useState 훅 구현의 핵심은 렌더링 사이에서 상태값이 초기화되지 않아야 한다는 것이다. 만약 useState를 아래와 같이 구현한다면, 새로운 렌더링이 진행되어 useState를 호출할 때마다 값이 초기화될 것이다.

function useState(initialState) {
  let _state = initialState;
 
  function setState(newState) {
    _state = newState;
  }
  return [_state, setState];
}

이 문제를 어떻게 해결할 수 있을까? 답은 클로저에 있다.

closureExample.js
function Counter() {
  let counter = 0;
 
  function getValue() {
    return counter;
  }
 
  function increase() {
    counter += 1;
  }
 
  return { getValue, increase };
}
 
let counter = Counter();
counter.increase();
counter.increase();
counter.getValue(); // 2

클로저는 선언될 당시의 주변 환경을 기억했다가 이후에도 접근할 수 있는 함수이다. 클로저 안의 변수는 마치 "죽지 않는 변수"와 같다. 함수 Counter()의 실행이 끝나더라도 변수 counter는 사라지지 않는다. 따라서 이후에 increase(), getValue() 함수를 호출했을 때도 예상대로 올바르게 동작할 수 있는 것이다.

클로저를 활용하여 useState의 코드를 간단하게 작성해보면 아래와 같다.

src/core/React.js
function React() {
  let _state;
 
  function render() {
    /*...*/
  }
 
  function useState(initialState) {
    _state = _state || initialState;
    function setState(newState) {
      _state = newState;
      render();
    }
    return [_state, setState];
  }
 
  return { useState };
}
src/App.jsx
const { useState, render } = React();
// prettier-ignore
function App() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p> you clicked {count} times </p>
      <button onClick={() => { setCount(count + 1); }}> increase count </button>
    </div>
  );
}

위 코드의 작동은 구체적으로 다음과 같다.

  1. 컴포넌트가 처음 마운트 될 때

    • App0을 매개변수로 하여 useState를 호출한다.
    • 맨 처음에 변수 _stateundefined이다. 따라서 _state은 전달된 값 0으로 초기화된다.
    • useState는 상태값 _state와 setter 함수 setState를 반환한다.
  2. setState가 호출되었을 때

    • 내부 변수 _state의 값을 1로 수정한다.
    • 해당 컴포넌트의 리렌더링을 발생시킨다.
  3. 리렌더링이 발생되었을 때

    • App0을 매개변수로 하여 다시 useState를 호출한다.
    • 이번에는 _stateundefined가 아니므로 전달된 값 0은 무시되고, _state는 여전히 1이다.
    • useState는 상태값 _state와 setter 함수 setState를 반환한다.

이제 상태가 여러개인 경우를 구현해보자. 복잡하게 생각할 필요 없이, 배열과 인덱스를 사용하여 쉽게 구현할 수 있다.

src/core/React.js
function React() {
  let _states = [];
  let cursor = 0;
 
  function render() {
    /*...*/
    cursor = 0;
  }
 
  function useState(initialState) {
    if (_states.length === cursor) _states.push(initialState);
 
    const state = _states[cursor];
    function setState(newState) {
      _states[cursor] = newState;
      render();
    }
 
    cursor += 1;
    return [state, setState];
  }
 
  return { useState };
}
src/App.jsx
const { useState, render } = React();
// prettier-ignore
function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("John");
 
  return (
    <div>
      <p> you clicked {count} times </p>
      <button onClick={() => { setCount(count + 1); }}> increase count </button>
      <p> your name is {name} </p>
      <button onClick={() => { setName("David"); }}> change name </button>
    </div>
  );
}

컴포넌트 내에서 useState가 한번 등장할 때마다 cursor가 1씩 증가하면서 배열의 새로운 칸에 상태를 정의하는 식으로 구현했다. 렌더링이 끝난 뒤에는 다시 cursor0으로 초기화 해주어 컴포넌트가 다시 호출되었을 때 배열 _states를 첫 번째 칸부터 읽을 수 있도록 한다.

이제 문제의 코드를 다시 읽어보자.

function App() {
  const [ageFirst, setAgeFirst] = useState(true);
 
  let age, setAge, name, setName;
 
  if (ageFirst) {
    [age, setAge] = useState(20);
    [name, setName] = useState("John");
  } else {
    [name, setName] = useState("John");
    [age, setAge] = useState(20);
  }
 
  return (
    //...
  );
}

첫 번째 렌더링에서는 _states의 0번째 칸에 true가 들어간다. 또한 ageFirsttrue이므로, _states의 1번째 칸에 20, 2번째 칸에 John이 들어간다. 또한 변수 agename에는 useState에서 반환된 값인 20, John이 각각 들어간다.

반면 boyFirstfalse로 바뀌고 난 뒤에는 이야기가 달라진다. ageFirst_states의 0번째 칸에서 상태값을 받는 것은 문제가 없지만, 이번에는 name_state[1]의 값인 20이 들어가고, age_state[2]의 값인 John이 들어간다. 여기서 useState의 인자는 아무런 영향도 끼치지 않는다.

훅의 규칙: 반복문, 조건문, 또는 중첩된 함수 안에서 훅을 호출해서는 안된다.

이제 위의 규칙이 무엇을 의미하는지 알 수 있다. React는 그저 훅이 등장하는 순서대로 리스트에서 값을 찾아 전달해주는 역할을 할 뿐이다. 변수명이 age인지 name인지는 전혀 중요하지 않다. 훅이 실행되는 순서가 항상 일정하게 유지되는 것이 중요하고, 위의 규칙은 순서가 바뀌지 않기 위한 필요조건 중 하나를 제시한 것이다.


참고자료