Front-End/React

[React] useState : console.log에 이전 state가 출력되는 이유?

Y0ungZ 2024. 9. 14. 07:09
🍀목차
글의 목적
useState 문서 읽어보기
state 업데이트 큐
flushSync
useEffect

 

글의 목적

 React로 함수 컴포넌트를 만들다 보면 자연스럽게 React Hooks들을 사용하게 된다. 그중에서도 useState는 컴포넌트에 state 변수를 추가해서 상태 관리와 업데이트를 하게 해 주는 Hook이다. 그런데 state 업데이트 후 바로 console.log를 사용해 출력해 보면 이전 상태값이 출력된다.

이유가 무엇일까? useState 작업이 비동기이기 때문에? 더 자세히 설명할 수는 없을까?

많이 사용함에도 이처럼 설명을 못하는 부분이 있어 글을 작성하게 되었다. 이번 글은 useState를 사용하며 헷갈렸던 state 업데이트에 관해 알아보았다.

 

 

useState 문서 읽어보기

문서

 

const [state, setState] = useState(initialState)

 

useState는 정확히 두 개의 값을 가진 배열을 반환한다. 

  • 현재 state. 
  • state를 다른 값으로 업데이트하고 리렌더링을 촉발할 수 있는 set 함수.

 

 사용자가 제공한 새로운 값이 Object.is에 의해 현재 state와 동일하다고 판정되면, 최적화를 위해 React는 컴포넌트와 그 자식들을 리렌더링 하지 않는다. 추가로, React는 state 업데이트를 batch 하는데(React 18부터 Concurrent Mode로 기본 활성화), 이는 모든 이벤트 핸들러가 실행되고 set 함수를 호출한 후에 화면을 업데이트한다는 의미이다. 이렇게 단일 이벤트 중에 여러 번 리렌더링 되는 것을 방지할 수 있다.

 

// ...
const [name, setName] = useState('Taylor');

function handleClick() {
  setName('Robin');
  console.log(name); // 아직 "Taylor"이다.
}
// ...

 

set 함수를 호출해도 이미 실행 중인 코드의 현재 state는 변경되지 않는다. set 함수는 다음 렌더링에서 반환할 useState에만 영향을 준다. 아래 코드도 살펴보자.

 

// age가 42라고 가정.

function handleClick() {
  setAge(age + 1); // setAge(42 + 1)
  setAge(age + 1); // setAge(42 + 1)
  setAge(age + 1); // setAge(42 + 1)
}

 

handleClick은 setAge(age + 1)세 번 호출함에도 age는 43이 된다. set 함수를 호출해도 이미 실행 중인 코드에서 age state 변수가 업데이트되지 않기 때문이다. 따라서 각 setAge(age + 1) 호출은 setAge(43)이 된다. 이것은 state가 스냅샷처럼 동작하기 때문이다.

 

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setTimeout(() => {
          alert(number);
        }, 3000);
      }}>+5</button>
    </>
  )
}

// https://ko.react.dev/learn/state-as-a-snapshot

 

alert에 출력되는 number 값은 무엇일까? setTimeout으로 3초 뒤에 출력하니까 5? 정답은 0이다.

state 변수의 값은 이벤트 핸들러의 코드가 비동기적이더라도 이번 렌더링 내에서 절대 변경되지 않는다(다음 렌더링에 변경된다). 이 값은 컴포넌트를 호출해 React가 UI의 스냅샷을 찍을 때 고정된 값이다. 그렇기에 위의 setAge의 경우에도 3살이 늘어나는 것을 예상했음에도 1살만 늘어났다. 이를 현재의 스냅샷에서 최신 state로 읽으려면 state 갱신 함수를 사용하면 된다.

 

state 업데이트 큐

문서

 

 state 변수를 설정하면 다음 렌더링이 큐에 들어간다. 각 렌더링의 state 값은 고정되어 있기에 해당 렌더링에서 set 함수를 얼마나 호출하든 현재 렌더링의 state는 고정되게 된다.

이 외에 추가적으로 생각해야 될 개념은 위에서 잠시 알아본 batching이다. React는 batching을 통해 state 업데이트를 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다린다. 이 때문에 리렌더링은 모든 setNumber() 호출이 완료된 이후에만 일어난다. 웨이터처럼 모든 주문이 끝날때까지 기다렸다가 주방으로 가는 것과 동일하다.

 batching을 통해 너무 많은 리렌더링이 발생하지 않고도 여러 컴포넌트에서 나온 다수의 state 변수를 업데이트할 수 있다. 이는 다른 말로 이벤트 핸들러와 그 안에 있는 코드가 완료될 때까지 UI가 업데이트되지 않는다는 의미기도 하다. 결과적으로 React는 batching을 통해 앱을 훨씬 빠르게 실행할수 있게 된다. 

 

다음 렌더링 전에 동일한 state 변수를 여러 번 업데이트 하고 싶다면 setNumber(n => n+1)과 같이 이전 큐의 state를 기반으로 다음 state를 계산하는 함수를 전달하면 된다. 이 n => n+1을 업데이터 함수라고 부른다. 

 

import { useState } from 'react';

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

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Increase the number</button>
    </>
  )
}

 

버튼을 클릭하면 number에는 어떤 값이 들어가있을까?

n queued update returns
0 "5로 바꾸기" 5
5 n => n + 1 5 + 1 = 6
6 "42로 바꾸기" 42

 

setNumber가 연속으로 호출되며 가장 마지막의 42로 고정된다.

 

 

이제 아래의 코드의 결과가 이해가 간다.

  • React 상태 업데이트가 일어나면 다음 렌더링을 큐에 예약한다. 이 리렌더링이 완료되기 전까지 상태 값은 업데이트되지 않는다.
  • console.log는 이전 상태를 스냅샷으로 갖고 있기에 업데이트된 count가 출력되지 않는다. 
import { useState } from 'react';
import Button from '@/components/button/Button';

const Test = () => {
  const [count, setCount] = useState(0);

  const handleCount = () => {
    setCount(count + 1);
    console.log(count);
  };

  return (
    <div>
      <h1>count : {count}</h1>
      <Button variant="contained" color="primary" onClick={handleCount}>
        plus +
      </Button>
    </div>
  );
};

export default Test;

 

 

 

깜빡이는 렌더링 전의 count 값이 스냅샷으로 고정되어 출력되고 있는 것을 볼 수 있다. 

 

 

이제 상태 업데이트 후 값을 바로 확인하는 방법을 알아보자.

 

flushSync

📌 flushSync는 애플리케이션 성능을 저하시킬 수 있는, 대부분의 경우 권장되지 않는 함수이다. 

 

드물지만 DOM에 접근하기 위해 React가 화면을 더 일찍 업데이트하도록 강제해야 하면 flushSync를 사용할 수 있다.

이 함수는 React의 자동 배치 처리에서 벗어나 보류 중인 모든 작업을 강제로 처리하여 동기적으로 업데이트를 적용해 준다. 특정 코드 블록 내에서 상태 업데이트를 동기적으로 처리하고, 그 즉시 변경 사항이 반영되도록 해주는 것이다.

 

import { useState } from 'react';
import { flushSync } from 'react-dom';
import Button from '@/components/button/Button';

const Test = () => {
  const [count, setCount] = useState(0);

  const handleCount = () => {
    setCount(count + 1);
  };

  flushSync(() => {
    console.log(count);
  });

  return (
    <div>
      <h1>count : {count}</h1>
      <Button variant="contained" color="primary" onClick={handleCount}>
        plus +
      </Button>
    </div>
  );
};

export default Test;

 

 

 

다만, flushSync는 영상에서도 보다시피 사용하는 것 자체로 빨갛게 오류 메시지가 뜨는 권장되지 않는 함수다.

React가 여러 상태 업데이트를 batch로 처리하는 이유는 최소한의 렌더링만 일어나도록 최적화하기 위해서인데, 이를 flushSync는 즉시 렌더링이 일어나도록 강제하기 때문이다. 때문에 불필요한 렌더링을 유발할 수 있으며 useEffect 같은 후처리 로직과의 타이밍 충돌 등의 의도치 않은 동작으로 이어질 수 있다. 

 

 

마지막으로, useEffect를 알아보자.

 

useEffect

useEffect는 외부 시스템(브라우저 DOM 등)과 컴포넌트를 동기화하는 React Hook이다. 

useEffect(setup, dependencies?)

 

우선, 컴포넌트에 Effect를 무작정 추가하는 것도 권장되지 않는다. Effect는 React를 벗어나 특정 외부시스템과 동기화하기 위해 사용된다. 그렇기에 단순 다른 상태에 기반해 일부 상태를 조정하는 경우(state나 props이 변경될 때 컴포넌트의 state를 업데이트하는 경우)에는 필요하지 않다. 그렇기에 꼭 사용되어야 하는지 잘 생각해 보자. (useEffect에 관해서도 모르는 부분이 많아 다음 포스팅으로 작성하려고 한다.)

 

 간단히 알아보면 useEffect는 컴포넌트가 리렌더링된 후에 실행되는 훅이다. dependencies 배열에 state를 넣어 의존성을 추가하면, 해당 state 변경 -> 리렌더링 완료 -> useEffect가 실행되고 console.log에는 최신 상태의 count가 출력된다.

 

import { useState, useEffect } from 'react';
import Button from '@/components/button/Button';

const Test = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(count);
  }, [count]);

  const handleCount = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <h1>count : {count}</h1>
      <Button variant="contained" color="primary" onClick={handleCount}>
        plus +
      </Button>
    </div>
  );
};

export default Test;

 

 

 

 

 

 이렇게 state 업데이트를 해도 이전 상태가 출력되는 이유는 useState가 비동기인 이유도 있지만 더 자세하게는,

React가 상태 업데이트를 배치(batch) 처리하여 해당 렌더링 전까지 상태가 변경되지 않아, console.log에는 고정된 이전 상태값이 출력된다로 이해할 수 있다. 

 추가로, 업데이트된 값을 바로 출력해 보려면 flushSync, useEffect를 사용하는 것이 방법 중 하나이다. 그러나, flushSync는 권장되지 않는 함수이며 useEffect도 본래 목적은 외부 시스템과 컴포넌트를 동기화하는 Hook임을 알아두자.

 

 

 

참고자료

https://ko.react.dev/reference/react/useState

https://ko.react.dev/learn/state-as-a-snapshot

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

https://ko.react.dev/reference/react-dom/flushSync