[Javascript] 바닐라 JS로 useState 만들기

박세화·2024년 1월 10일
0

Javascript

목록 보기
8/12

💡 React의 여러 hook 들은 클로저의 원리를 활용하여 만들어졌다.

그 중 가장 대표적인 상태관리 useState hook을 클로저를 이용해 구현해보자.
위의 예시들을 보면 하나의 큰 함수 내에서 계산을 실행하는 함수를 리턴하여, 외부에서 이 계산 함수 내부에 접근하지 않고 계산을 이용할 수 있었다.

useState 에서도 비슷하게, React 라는 하나의 큰 함수 내부에서 클로저 개념을 이용해 상태를 변경하는 함수를 만들어 놓아 외부에서 이를 이용하도록 할 수 있다.

처음에 작성한 코드

let _state;

function useState(initialValue) {
  const state = _state || initialValue;

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

  return [state, setState];
}

const [value, setValue] = useState(2);

console.log(value);  //2
setValue(4);
console.log("changed into", value);  //changed into 2

이렇게 작성하면 setState 안에서 전역 변수를 업데이트 했기 때문에 value 값이 바뀔 줄 알았는데, 값이 바뀌지 않고 계속 2로 출력되었다.

해당 코드의 동작을 자세히 살펴보면
1. const [value, setValue] = useState(2) 에서 useState 함수는 매개변수 2를 전하고 상태값과 세터함수를 리턴하며 실행이 종료된다.
2. useState 내부의 state 변수에는 초기값으로 전달된 2가 담긴다. 따라서 첫번째 콘솔의 console.log(value) 출력값은 2가 된다.
3. setValue(4) 함수를 호출하여 매개변수로 4를 전달했다. 이제 밖의 let _state 값은 4로 변한다.

➡️ 착각했던 부분이 이 부분이었다. setState 함수 내에서 상태를 업데이트해도, useState 함수가 이미 수명을 다했기 때문에 const state = _state || initialValue 라인은 실행되지 않는다.

그래서 리턴값 [state, setState] 에 변경사항이 전혀 반영되지 않으므로, 두 번째 콘솔로그에 찍힌 값도 2였던 것이다.

그렇다면 const state = _state || initialValue 라인이 바깥의 let _state 값을 참조할 수 있어야 value 값이 업데이트될 것이다.
⭐️ 해당 라인을 클로저로 만들어야 한다.

1️⃣ 1차 코드 수정-클로저 개념 적용

let _state;

function useState(initialValue) {
  const state = () => _state || initialValue;

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

  return [state, setState];
}

const [value, setValue] = useState(2);

console.log(value());  //2
setValue(4);
console.log("changed into", value());  //changed into 4

value가 다시 실행하는 과정(함수 컨텍스트가 다시 생성)에서 _state 를 다시 참조하기 때문에 업데이트가 된다.

상태가 업데이트 되지 않는 문제는 해결되었지만, 현재 useState 가 반환하는 value 값이 함수이기 때문에 아직 불완전하다.

2️⃣ 2차 코드 수정-렌더링 함수 작성

이런 경우엔 리턴값은 첫 번째 코드처럼 해주되, setState 실행 이후 렌더링을 새로 해주어 useState 함수가 업데이트된 전역변수 _state를 참조하도록 해줘야한다.

//index.html
<body>
  <div id="root">Custom UseState</div>
</body>


//index.js
let _state;

function useState(initialValue) {
  const state = _state || initialValue;
  //_state가 true면 _state 할당, false(null, undefined, 0, false)면 initialValue 할당

  const setState = (newValue) => {
    _state = newValue;
    render();
  };

  return [state, setState];
}

//html을 리턴하는 컴포넌트
function Component() {
  const [value, setValue] = useState(2);

  console.log(value); //예상 : 2
  setValue(4);
  console.log(value);  //예상 : 4
  
  return `
  <div>
    <strong>state: ${value} </strong>
  </div>
`;
}

//전체 DOM을 그려주는 렌더링 함수 작성
function render() {
  const $root = document.querySelector("#root");
  $root.innerHTML = Component();
}

render();

const state = _state || initialValue을 함수식에서 다시 변수식으로 바꿨다.
그리고 전역의 let _state를 가지는 컴포넌트와 렌더링 함수를 작성했다.

나의 예상 😶

  • 마지막줄 render() 을 통해 렌더 함수가 실행되고, innerHTML에 Component() 가 할당되면서 Component 컴포넌트가 최초 렌더링된다. 이때 _stateundefined

  • const [value, setValue] = useState(2) 라인을 통해 최초 value 값은 2가 된다. 첫번째 콘솔로그는 2가 출력된다.

  • setState 함수가 실행되면서 새로운 값으로 전역의 _state 를 4로 업데이트한다.
    그리고 render()을 통해 전체 Component 컴포넌트를 다시 렌더링한다.

  • useState 가 한번 더 실행되면서 전역의 let _state를 참조하는데, 이때 _state 값이 4로 업데이트 되어 있으므로 state 변수에 4를 할당하여 리턴한다. (initialValue는 무시된다)

  • 두번째 콘솔로그에 4가 출력된다.

하지만 setValue 함수를 저렇게 호출하면 무한루프에 빠진다

⭐️ setState(4)를 통해 Component 컴포넌트 전체가 두 번째로 리렌더링 될 때, setState(4)가 또 실행이 되고, 컴포넌트가 또 다시 렌더링되고, 다시 setState(4) 가 실행되고... 이 순서로 무한루프에 빠지게 된다.
결국 두번째 콘솔로그가 실행되기 전에 스택이 가득차 에러가 나게 된다.

3️⃣ 3차 코드 수정-버튼을 누르면 상태가 변화하도록 코드 작성

//index.html
<body>
  <div id="root">Custom UseState</div>
  <button onClick="increase()">Click!</button>
</body>

//Component 컴포넌트 수정
function Component() {
  const [value, setValue] = useState(2);

  window.increase = () => setValue(value + 1);

  return `
  <div>
    <strong>state: ${_state} </strong>
  </div>
`;
}

html에 버튼 태그를 추가하고 온클릭 시 윈도우 객체로부터 불러와서 setValue(value + 1)를 실행하도록 해주었다.
이렇게 하면 컴포넌트 전체가 리렌더링 되더라도 위의 경우처럼 setValue 가 맘대로 호출되지 않으므로 value값이 잘 업데이트된다.


여러 개의 상태를 관리할 수 있는 useState 작성

위의 useState는 단 한개의 상태만을 관리할 수 있다. 하지만 보통은 여러 상태를 동시에 관리해야 하므로, 여러 상태값을 배열에 담아 관리하면 될 것이다.

1️⃣ 1차 코드

let _state = []; 
let timeOfUseState = 0; 

function useState(initialValue) {
  const state = _state[timeOfUseState] || initialValue;

  const setState = (newValue) => {
    _state[timeOfUseState] = newValue;
    render();
  };
  timeOfUseState = timeOfUseState + 1;  //useState 함수가 실행될 때마다 값이 증가

  return [state, setState];
}

function Component() {
  const [first, setFirst] = useState(1);
  const [second, setSecond] = useState(100);

  window.first = () => setFirst(first + 1);
  window.second = () => setSecond(second + 1);

  return `
  <div>
    <strong>first: ${first} </strong><br>
    <strong>second: ${second} </strong>
  </div>
`;
}

function render() {
  const $root = document.querySelector("#root");
  $root.innerHTML = Component();

  timeOfUseState = 0;  //컴포넌트가 새로이 렌더링 되기 전 이 횟수값을 0으로 바꿔야 한다
}

render();
  • _state 안에는 상태값들이 들어가고, _timeOfUseState는 useState가 사용된 횟수를 나타낸다.

  • 전반적인 것은 원래와 동일하지만, 여러 가지 상태값 중 어느 상태가 변화되었는지를 파악해야하기 위해 배열의 인덱스 값을 이용한다.

  • 하지만 이 코드는 First! 버튼은 의도했던 대로 잘 동작하지만, Second! 버튼을 누르면 second의 값이 아니라 first의 상태값이 101로 바뀌어 쭉 고정되는 문제가 발생했다.

    ➡️ 원인 🥲
    1) 최초 렌더링 후, _state = [] , timeOfUseState = 2 이다.
    2) setSecond 함수를 실행하면, timeOfUseState = 0 이며 배열의 0번째 인덱스에 101이 담기며 _state = [101] 이 된다.

    💡 window.second = () => setSecond(second + 1) 는 클로저이다.
    따라서 생성되는 시점의 timeOfUseState을 참조하게 되고, 이 값은 생성 시점에 0이었다.

    3) 리렌더가 실행되며 timeOfUseState = 0 으로 초기화된다.
    4) const [first, setFirst] = useState(1) 가 실행될 때 useState 내부에서는 더이상 _state 가 비어있지 않기에 first 에 101을 할당한다.
    5) setSecond 함수를 몇 번을 더 실행하든지 상관없이, second 의 값이 업데이트된 적이 없었기 때문에 계속 first엔 101이 찍힌다.


2️⃣ 2차 코드

...

function useState(initialValue) {
  const index = timeOfUseState;   //추가된 라인
  const state = _state[index] || initialValue;

  const setState = (newValue) => {
    _state[index] = newValue;  //전역의  timeOfUseState 를 바로 참조하지 않고 상위 스코프의 index 값 참조
    render();
  };
  timeOfUseState = +1;

  return [state, setState];
}

...

원인은 index값의 참조에 있었다.
수정 전 코드에서 원하는 결과가 나오지 않았던 이유는 setSecond 함수를 실행했을 때, timeOfUseState = 0 이라서 배열의 0번째 인덱스에 101이 담기며 _state = [101] 이 되었기 때문이었다.

따라서 함수 실행 시점에, 업데이트된 timeOfUseState 값을 참조하기 위해서 index 라는 변수에 timeOfUseState를 할당한다. 즉, 2가 할당된다.

따라서 _state = [ , 101 ] 로 순서에 맞게 배열이 업데이트 된다. (0번 인덱스는 빈 채로)


3️⃣ 3차 코드(완성!) - 같은 값으로 업데이트할 경우엔 리렌더링 방지하기

let _state = []; 
let timeOfUseState = 0;
let renderCount = 0;

function useState(initialValue) {
  const index = timeOfUseState; 
  const state = _state[index] || initialValue;

  const setState = (newValue) => {
    if (newValue === state) return;  //매개변수가 기존 상태와 같으면 함수 실행 종료
    _state[index] = newValue;
    render();
  };
  timeOfUseState = timeOfUseState + 1;

  return [state, setState];
}

function Component() {
  const [first, setFirst] = useState(1);
  const [second, setSecond] = useState(100);  //초기값과 같은 값인 100을 넣어본다

  window.first = () => setFirst(first + 1);
  window.second = () => setSecond(100);

  return `
  <div>
    <strong>first: ${first} </strong><br>
    <strong>second: ${second} </strong><br>
    <strong>Rereder Count: ${renderCount} </strong><br>
  </div>
`;
}

function render() {
  const $root = document.querySelector("#root");
  $root.innerHTML = Component();

  timeOfUseState = 0;
  renderCount = renderCount + 1;
}

render();

0개의 댓글