하루하루 꾸준히, 인생은 되는대로

리액트

useState와 클로저

긤효중 2023. 4. 22. 18:12

클로저

자바스크립트에는 클로저라는 것이 존재한다.  클로저는 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합이다. 예를들어서 다음과 같은 경우가 존재한다.

 

function makeFunc() {
  const name = "Mozilla";
  function displayName() {
    console.log(name);
  }
  return displayName;
}

const myFunc = makeFunc();
myFunc();

makeFunc의 함수 안에 name이라는 변수와 displayName이라는 함수가 선언되었다.

makeFunc는 displayName이라는 함수를 반환하는데, displayName은 makeFunc의 name이라는 변수를 참조한다.

 

myFunc의 변수는 makeFunc의 반환값인 displayName()을 참조하게 되고, myFunc가 호출될때 displayName의 함수가 실행되고 name의 변수값 "Mozila"가 출력된다.

 

displayName()은 makeFunc()함수 안에서 정의되고 반환되었지만, makeFunc()의 컨텍스트에서 벗어나도, name변수를 참조 할 수 있게 된다. displayName()이 생성될 떄의 렉시컬 환경(makeFunc)을 기억하고 있기 때문이다.

 

function outer() {
  let outerVar = 1;
  function inner() {
    console.log(outerVar);
  }
  return inner;
}
const closure = outer();
closure(); // 출력: 1

 

outerVar는 outer가 종료되었을 떄 없어져야 하지만, outerVar는 끝까지 살아남아서 1을 출력한다.

outer를 호출하면 그 결과로 inner라는 클로저를 반환받게 된다.

 

이 inner가 선언되었을 당시 렉시컬환경에는 outerVar가 이미 존재한다.따라서 이 inner가 사라지지 않는 한,

outerVar도 계속 살아있게 된다.


useState와 클로저

useState도 이러한 클로저를 적극적으로 활용한다. 함수가 호출되어도 살아남는 변수를 얻기 위해서는 클로저를 활용하면 된다. 이것을 바탕으로 useState는 다음과 같은 형태가 될 수 있다.

 

function useState(initalValue) {
  var _val = initalValue;
  //지역 변수 _val

  function state() {
    return _val;
    //state함수는 클로저이다. _val변수를 선언하는데, 이는 외부에서 끌어온 변수이다.
  }

  function setState(newVal) {
    _val = newVal;
    //새로운 값으로 _val를 갱신한다

    //외부에서 이 함수가 관리하는 상태와, 상태를 변경할 수 있는 함수를 반환한다.
    //외부에서도 이 함수를 호출해 상태를 관리하고 갱신한다.
  }
  return [state, setState];
}

//useState함수의 외부
var [foo, setFoo] = useState(0);
setFoo(1);
console.log(foo());
setFoo(2);
console.log(foo());

useState는 초기값을 인자로 받아서, 그 값을 지역변수 _val에 저장한다. 그 후 state함수와 setState함수를 각각 정의한다.

state는 _val을 반환하는 클로저이다. state가 선언될 당시 렉시컬 환경에 _val변수는 존재한다.

 

setState함수는 newVal이라는 새 값을 인자로 받아 _val를 갱신한다. 최종적으로 state와 setState를 배열에 넣어서 반환한다.  중요한 점은 내부 변수 _val에 state와 setState를 써야만 접근할 수 있다는 점이다.

 

이제 이 useState를 사용해보자. 간단한 Counter를 만들고 이 useState를 사용하였다.

function Counter() {
  const [count, setCount] = useState(1);
  return {
    click: () => setCount(count() + 1),
    render: () => console.log(count()),
  };
}

const C = Counter();
C.render(); //1
C.click();
C.render(); //2

console.log()를 상태를 확인하게 하였고, click을 실행하면 count의 상태가 1 변화한다.

하지만 상태를 얻기 위해서, 실제 리액트의 state는 getter함수를 실행하지 않는다. 그래서 _val을 직접 반환하도록 수정하면 상태가 바뀌지 않는 문제점이 발생한다.

 

function useState(initalValue) {
  var _val = initalValue;
  //지역 변수 _val

  function setState(newVal) {
    _val = newVal;
    console.log(_val);
    //새로운 값으로 _val를 갱신한다

    //외부에서 이 함수가 관리하는 상태와, 상태를 변경할 수 있는 함수를 반환한다.
    //외부에서도 이 함수를 호출해 상태를 관리하고 갱신한다.
  }
  return [_val, setState];
}

//useState함수의 외부

function Counter() {
  const [count, setCount] = useState(1);
  return {
    click: () => setCount(count + 1),
    render: () => console.log(count),
  };
}

const C = Counter();
C.render(); // 첫번쨰 useState호출 ,이때의 _val을 _val1로 표현
C.click();
C.render(); // 두번쨰 useState호출, 이때의 _val을 _val2로 표현

/*
_val1의 값 1 출력
_val1의 값 1을 교체
_val2의 값 출력
*/

Counter에서 render를 두 번 호출하면, 두 개의 useState호출이 각각 다른 변수 _val를 가리키고,

결과적으로 다른 _val를 가리키기 떄문에 click을 호출해도 변화가 없는 것 처럼 동작한다.

 

이걸 해결한 코드는 다음과 같다.

const MyReact = (function() {
  let _val // hold our state in module scope
  return {
    render(Component) {
      const Comp = Component()
      Comp.render()
      return Comp
    },
    useState(initialValue) {
      _val = _val || initialValue
      function setState(newVal) {
        _val = newVal
      }
      return [_val, setState]
    }
  }
})()

MyReact는 익명함수로부터 두 개의 클로저를 받아 저장하고 있다.

 

render메서드는 함수형 컴포넌트를 랜더링 해주는 메서드이다.

 

MyReact 모듈의 실행 컨텍스트가 생성되면서, _val 변수는 모듈 내에서 단 한번만 생성된다. 그리고 이후에 useState 함수가 실행될 때마다, _val 변수는 먼저 초기값으로 초기화된다. 하지만 이후에 useState 함수가 호출될 때마다, setState 함수에서 _val을 갱신하는 것은 계속해서 동일한 _val 변수를 갱신하고 있기 때문에, 이전에 갱신된 값을 계속해서 가지게 된다.


그럼 이제 함수형 컴포넌트를 만들고 useState를 사용해보자.

const { useState } = require('react');

const MyReact = (function () {
  let _val; // hold our state in module scope
  return {
    render(Component) {
      const Comp = Component();
      Comp.render();
      return Comp;
    },
    useState(initialValue) {
      _val = _val || initialValue;
      function setState(newVal) {
        _val = newVal;
      }
      return [_val, setState];
    },
  };
})();

function Counter() {
  const [count, setCount] = MyReact.useState(0);
  return {
    click: () => setCount(count + 1),
    render: () => console.log(count),
  };
}

let newApp;
newApp = MyReact.render(Counter);
newApp.click();
newApp = MyReact.render(Counter);

MyReact의 render메서드로 Counter를 랜더링한다. 

그후 MyReact의 useState메서드를 사용해 초기값을 undefined -> 0으로 변경한다.

그 후 [0,_val에 접근하는 setter]함수를 반환해 준다. 그 후 Counter는 click,render메서드를 갖고 있는 객체를 반환한다.

 

이떄 Counter는 모듈 스코프의 _val변수를 바라보게 되므로 아까와 다르게 useState의 setState가 잘 작동하는 모습이다.

 


결과적으로 함수형 컴포넌트에서 이렇게 [state,setState]를 이용할때 어딘가에 존재하는 state가 바뀌게 된다.

이 state가 새로운 state가 되는 시점은 랜더링 이후가 된다.

 

프로젝트를 할 떄 내 생각대로 setState가 작동하지 않은 적이 종종 있다. 예를 들면 아래와 같은 상황이다.

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

useEffect(() => {
  setState(state + 1);
  console.log(state); //왜 state가 그대로....??
}, []);

 

setState는 함수는 절대 비동기 함수가 아니다

 

랜더링이 일어나지 않으면, state는 해당 시점에 참고하고 있는 클로저의 상태를 참조한다

결국 위에서는 useEffect의 콜백이 마무리 되면 다시 랜더링이 발생하는데, console.log(state)의 상태에서는,

새로 갱신된 클로저의 상태가 아니라, 현재 바라보고 있는 클로저의 상태가 된다.


batch update

state값을 변경하면 해당 컴포넌트와 해당 state를 props로 받은 자식 컴포넌트들의 랜더링이 발생한다

이렇게 여러 state의 변경을 단일 업데이트로 처리하는 것을 batch update라고 부른다.

 

리액트 18에서는 automatic Batching이라는것을 통해 기존 batch update의 문제를 해결한다.

Batching?

state의 값이 변경되었을 경우, 해당 컴포넌트를 랜더링하는데, 불필요한 리랜더링을 막고자,

state를 변경하는 과정들을 한꺼번에 모아서 처리한다. 아래의 경우 state가 3이 될 것을 기대하지만, 실제로는 1로 변경된다.

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

결국 위의 코드는 아래와 같이 항상 이전 값인 0을 참조해서 0+1을 세 번 호출하는 결과가 발생하게 된다.

setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

비유를 하자면, 식당에서 음식을 주문할 때 음식을 부를떄마다 주방에 가는게 아니라, 모든 주문을 끝나고 주방에 가는 것과 비슷한 것이다!

https://react.dev/learn/queueing-a-series-of-state-updates#react-batches-state-updates

이러한 것을 Batching이라고 한다. Batching은 일반적으로 UI 이벤트의 경우에 작동하고,

 

React 18 이전에서는 setTimeout이나 Promise 겍체 내에서와 같은 시간과 분리된 비동기 작업에서는 적용되지 않는다. 


이전과 같은 상태 값을 바라보기 위해서는 setCount의 인자로 이전 상태를 참조하는 함수를 전달하면 된다.

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        //이전 상태를 참조하는 함수를 추가함
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}

Automatic Batch

리액트 18에서는 이벤트 핸들러 이외의 setTimeout,promise 등에서도 batch를 적용한다.

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
  //batch를 사용
}

//setter가 두 번 실행된다. 
//batch가 사용되지 않는다
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will render twice, once for each state update (no batching)
}, 1000);

아래와 같은 setTimeout에서는 setter가 두 번 적용되는데 createRoot와 함께 모두 자동으로 batch가 적용된다.

// After React 18 updates inside of timeouts, promises,
// native event handlers or any other event are batched.

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}, 1000);