ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [JS] 바닐라 js로 useState를 만들어 보자
    Javascript 2025. 3. 23. 21:00

     

     

    우테코에서 한달이 조금 넘는 동안 미션을 쭉 진행해오면서, JS로 상태를 어떻게 관리해야할지 고민이 많았다.

    그러던 중, 자연스레 왜 요즘 FE 개발자들은 React를 채택했고,

    그 안에서 왜 React는 useState라는 훅을 만들어 상태를 관리하게 했을까 궁금해졌다.

     

    '내가 직접 구현해보면 이해할 수 있지 않을까?' 생각했고, 자료를 찾던 중 평소 즐겨 읽던 '황준일님의 블로그(글 맨 아래 출처)'를 보게 되었다.

     

    공부해보며 정말 많은 공부가 되었던 것 같다.

     

    기존 미션이었던 자동차 경주나, 로또 추첨기에서의 상태 관리는 다음과 같았다.

     

    constructor 안에 상태를 담아두고, get으로 꺼내오는 관리었다.

     

    미션의 규모가 작기도 했고, 복잡한 상태 관리는 없었다.

     

    그렇지만 과연 이러한 방식이 최선일지는 항상 의문이었다.

     

    그러나 핵심은 있었다.

     

    상태가 바뀌면 렌더링을 다시 하자.

     

    class Lotto {
      #numbers;
    
      constructor(numbers) {
        this.#numbers = numbers.sort((a, b) => a - b);
      }
    
      get numbers() {
        return this.#numbers;
      }
    }
    
    export default Lotto;
    
    
    
    export default class Component {
      constructor(element, props = {}) {
        if (!element) throw "no element";
        this.element = element;
        this.props = { ...props };
    
        /** 생략 **/
      }
    
    
      setState(newState) {
        this.state = { ...this.state, ...newState };
        this.render(); //렌더링 다시
      }
    }

     

     

     

    잡소리는 그만하고, 어떻게 구현하는지 차근차근 살펴보자.

     

    먼저, useState를 보면 항상 신기했던 점 이 있다.

     

    function Counter () {
      const [count, setCount] = useState(1);
    
      // 돔에서 직접 호출하기 위해 window(전역객체)에 할당
      window.increment = () => setCount(count + 1);
    
      return `
        <div>
          <strong>count: ${count} </strong>
          <button onclick="increment()">증가</button>
        </div>
      `;
    }

     

    Counter 컴포넌트가 다시 실행되어도 count의 값은 초기화 되지 않고 유지된다.

     

    어떻게 이런 현상이 가능한 것일까?

     

    useState는 첫 번째로 state, 두 번째로 state를 변경하는 setState 함수를 반환하는 구조이다.

     

    또한 setState로 상태를 변경하면 render가 다시 일어난다.

     

    아마도 아래와 같은 형태일 것이다.

    function useState(initState) {
      let state = initState; // state를 정의한다.
      const setState = (newState) => {
        state = newState; // 새로운 state를 할당한다
        render(); // render를 실행한다.
      }
      return [ state, setState ];
    }

     

    다만, 이런 구조라면 state에는 항상 1이 들어간다.

     

     

    그래서 state 값은 내부가 아닌 외부에서 관리되어야 한다. 

     

    let state = undefined; //밖에서 관리
    function useState(initState) {
      // state에 값이 없을 때만 초기화를 진행한다.
      if (state === undefined) {}
        state = initState;
      }
      const setState = (newState) => {
        state = newState; // 새로운 state를 할당한다
        render(); // render를 실행한다.
      }
      return [ state, setState ];
    }
    
    function Counter() { /*생략*/ }
    function render () { /*생략*/ }
    
    render();

     

     

     

    만약 state가 여러개라면 어떻게 될까? 

     

    function Counter () {
      const [count, setCount] = useState(1);
    
      window.increment = () => setCount(count + 1);
    
      return `
        <div>
          <strong>count: ${count} </strong>
          <button onclick="increment()">증가</button>
        </div>
      `;
    }
    
    function Cat () {
      const [cat, setCat] = useState('고양이');
    
      window.meow = () => setCat(cat + ' 야옹!');
    
      return `
        <div>
          <strong>${cat}</strong>
          <button onclick="meow()">고양이의 울음소리</button>
        </div>
      `;
    }
    
    function render () {
      app.innerHTML = `
        <div>
          ${Counter()}
          ${Cat()}
        </div>
      `;
    }

     

     

    하나의 useState로 관리되고 있기 때문에 count와 cat이 같은 값을 보여주게 된다. 

     

    이를 해결하기 위해 외부의 state 개수를 useState가 실행되는 횟수만큼 만들어주면 된다.

     

    let currentStateKey = 0; // useState가 실행 된 횟수
    const states = []; // state를 보관할 배열
    function useState(initState) {
      // initState로 초기값 설정
      if (states.length === currentStateKey) {
        states.push(initState);
      }
    
      // state 할당
      const state = states[currentStateKey];
      const setState = (newState) => {
        // state를 직접 수정하는 것이 아닌, states 내부의 값을 수정
        states[currentStateKey] = newState;
        render();
      }
      currentStateKey += 1;
      return [ state, setState ];
    }
    
    function Counter () { /*생략*/ }
    function Cat () { /*생략*/ }
    
    const render = () => {
      app.innerHTML = `
        <div>
          ${Counter()}
          ${Cat()}
        </div>
      `;
      // 이 시점에 currentStateKey는 2가 될 것이다.
      // 그래서 다시 0부터 접근할 수 있도록 값을 초기화 해야 한다.
      currentStateKey = 0;
    }

     

     

    여기까지가 useState의 핵심이다. 클로저의 개념이 여기서 쓰인다.

    useState 함수의 바깥에서 state를 관리하기 때문에 state의 값이 유지되는 것이다.

     

     

    만약 위 코드를 아래처럼 고치면 어떻게 될까?

    function useState(initialValue) {
      if (states.length === currentStateKey) {
        states.push(initialValue);
      }
    
      // const key = currentStateKey; //이 부분을 주석처리 후
    
      const state = states[currentStateKey]; //key가 아니라 currentStateKey로 접근
    
    
      const setState = (newState) => {
        states[currentStateKey] = newState; //key가 아니라 currentStateKey로 접근
        render();
      };

     

    먼저 클로저의 개념을 살펴보면
    클로저는 함수와 그 함수가 선언된 렉시컬 환경(Lexical Environment)의 조합이다.

    즉, 함수가 자신이 선언된 시점의 주변 환경(변수, 상수 등)을 기억하고 접근할 수 있는 것.


    function useState(initialValue) {
        // 1. key 변수가 선언되는 시점
        const key = currentStateKey;
        const state = states[key];
    
        // 2. setState 함수가 정의되는 시점
        const setState = (newState) => {
            states[key] = newState;  // 3. 클로저가 동작하는 부분
            render();
        };
    
        currentStateKey += 1;
        return [state, setState];
    }




    클로저가 동작하는 과정:

    1. 변수 선언 시점
       - const key = currentStateKey가 실행될 때, 현재의 currentStateKey 값이 key에 저장.
       - 예를 들어, Counter 컴포넌트에서는 key가 0이 되고, Cat 컴포넌트에서는 key가 1이 됨.

    2. 함수 생성 시점
       - setState 함수가 생성될 때, 이 함수는 자신이 생성된 환경(렉시컬 환경)을 기억한다.
       - 이 환경에는 key 변수의 값이 포함됩니다.

    3. 클로저의 실제 동작

       // Counter 컴포넌트의 경우
       window.increment = () => {
           setCount(count + 1);  // 여기서 setState가 호출됨
       };



       - setState가 나중에 호출될 때(버튼 클릭 시), 함수는 자신이 생성될 때 기억해둔 key 값을 사용함.
       - Counter의 setState는 항상 key = 0을 기억하고 있어서 states[0]을 업데이트
       - Cat의 setState는 항상 key = 1을 기억하고 있어서 states[1]을 업데이트

    4. 클로저가 중요한 이유
       - render() 함수가 호출될 때마다 currentStateKey는 0으로 초기화
       - 클로저가 없다면(두 번째 코드처럼 직접 currentStateKey 참조), setState 호출 시점의 currentStateKey 값을 사용하게 되어 잘못된 인덱스를 참조하게 됨.
       - 클로저를 통해 각 컴포넌트의 setState는 자신만의 고유한 key 값을 안전하게 유지할 수 있다.




     

     

     

    출처 : https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Make-useSate-hook/

     

    Vanilla Javascript로 React UseState Hook 만들기 | 개발자 황준일

    본 포스트는 React의 useState Hook의 작동방식에 대해 고민해보고, 구현해보고, 최적화하는 내용을 다룹니다. 필자는 React를 사용할 때 hook api들을 보면서 항상 신기했다. function Counter () { const [count, s

    junilhwang.github.io

     

     

    https://youtu.be/7mU7ARgrpfI?si=Dhz_M5bF7CKvW3Ts

     

Designed by Tistory.