본문 바로가기

state와 useState 훅 기초

컴포넌트 내부 상태와 props의 차이를 구분하고 useState로 값을 갱신해 토글 UI의 재렌더링을 구현합니다.

앞 글에서는 props로 부모 컴포넌트가 자식 컴포넌트에 데이터를 넘기는 방법을 배웠습니다. props는 컴포넌트 바깥에서 넘겨받는 읽기 전용 데이터였습니다. 하지만 웹 애플리케이션은 버튼 클릭이나 입력 같은 사용자의 상호작용에 따라 UI가 바뀌어야 하는 경우가 많습니다. 좋아요 버튼을 누르면 좋아요 수가 늘고, 입력 필드에 글자를 입력하면 그 글자가 화면에 바로 반영되어야 합니다.

React는 이렇게 컴포넌트 안에서 바뀔 수 있는 데이터를 관리하도록 state(상태)라는 개념을 제공합니다. 이 글에서는 state가 무엇이고 왜 중요한지, 그리고 함수 컴포넌트에서 state를 관리하는 가장 기본적인 방법인 useState 훅(Hook)을 자세히 알아봅니다.


state란 무엇인가?

state는 컴포넌트 안에서 관리되며 시간이 지나면서 바뀔 수 있는 데이터입니다. props가 바깥에서 넘겨받는 속성이라면, state는 컴포넌트가 스스로 가지고 있는 상태라고 이해할 수 있습니다. state가 바뀌면 React는 그 state를 쓰는 컴포넌트를 다시 렌더링(re-render)해 바뀐 UI를 화면에 반영합니다.

state는 화면 트리에서 그 컴포넌트가 놓인 위치에 속합니다. 다만 setter를 자식에게 넘기거나 자식의 콜백 요청을 받아 부모가 setter를 호출할 수도 있으므로, 상태를 가진 위치와 setter를 호출하는 위치는 구별해야 합니다. state는 사용자의 상호작용이나 네트워크 응답 같은 바깥의 요인으로 값이 바뀔 수 있습니다. 그리고 state가 바뀌면 React가 그 컴포넌트를 다시 그려 바뀐 값을 반영하는데, 이것이 선언적 UI의 핵심 원리입니다. 개발자가 DOM을 직접 조작하지 않아도 state만 바꾸면 React가 UI를 갱신합니다.


useState 훅 소개

예전에는 클래스형 컴포넌트에서만 state를 쓸 수 있었지만, React 16.8부터 도입된 훅 덕분에 함수 컴포넌트에서도 state를 비롯한 여러 React 기능을 쓸 수 있게 되었습니다. useState는 그 가운데 가장 기본적인 훅으로, 함수 컴포넌트에서 state를 관리하게 해 줍니다.

훅에는 지켜야 할 규칙이 있습니다. 훅은 React 함수 컴포넌트의 최상위(top level)에서만 호출해야 하며, 반복문이나 조건문, 중첩된 함수 안에서는 호출할 수 없습니다. 또 일반 자바스크립트 함수에서는 호출할 수 없고, React 함수 컴포넌트나 커스텀 훅에서만 호출해야 합니다. useState 같은 훅의 호출 순서가 렌더마다 같아야 React가 이전의 상태와 현재의 호출을 짝지을 수 있기 때문입니다. 조건에 따라 버튼을 보여 주는 것은 괜찮지만, 그 조건 안에서 useState를 호출해서는 안 됩니다.


useState 훅 사용법 기초

state 업데이트 큐 — 상태 추적

count=0의 한 핸들러에서 교체 세 번은 최종 1, updater 세 번은 0→1→2→3이다. 핸들러 snapshot과 대기 큐/다음 렌더의 다른 값을 추적할 가치가 크다.

Updater는 큐의 직전 결과를 받아 다음 값을 계산합니다

STATE SNAPSHOT & QUEUE

Updater는 큐의 직전 결과를 받아 다음 값을 계산합니다

같은 이벤트 핸들러의 count가 0일 때를 비교합니다. setter 호출 뒤 그 핸들러의 count는 계속 0이며, React는 큐를 처리해 다음 렌더의 값을 정합니다.

값 교체와 Updater의 독립 큐 비교같은이벤트의count0에서시작하는서로독립인두4노드3간선경로다. 왼쪽은1로교체세번뒤1, 오른쪽은앞선결과에1씩더해0→1→2→3이다. 두비교경로사이간선은없다. 현재핸들러count는두경우모두0으로유지된다.값 전달 · 다음 1현재 count: 0같은 핸들러 시작1로 교체첫 setter 요청1로 교체둘째 setter 요청1로 교체 · 다음 1셋째 setter 요청Updater · 다음 3현재 count: 0같은 핸들러 시작0 + 1 → 1앞 결과를 받음1 + 1 → 2앞 결과를 받음2 + 1 → 3다음 렌더 값
값 교체와 Updater의 독립 큐 비교 · 모바일같은이벤트의count0에서시작하는서로독립인두4노드3간선경로다. 왼쪽은1로교체세번뒤1, 오른쪽은앞선결과에1씩더해0→1→2→3이다. 두비교경로사이간선은없다. 현재핸들러count는두경우모두0으로유지된다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.값 전달 · 다음 1현재 count: 0같은 핸들러 시작1로 교체첫 setter 요청1로 교체둘째 setter 요청1로 교체 · 다음 1셋째 setter 요청Updater · 다음 3현재 count: 0같은 핸들러 시작0 + 1 → 1앞 결과를 받음1 + 1 → 2앞 결과를 받음2 + 1 → 3다음 렌더 값

값 전달 세 번 · 다음 값 1

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

모두 같은 스냅샷 0을 읽어 1을 전달합니다.

큐: 1로 교체 → 1로 교체 → 1로 교체
다음 렌더 count: 1

Updater 세 번 · 다음 값 3

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

각 함수는 앞선 결과를 이어받아 계산합니다.

큐: 0 → 1 → 2 → 3
다음 렌더 count: 3

현재 핸들러와 다음 렌더

setter는 현재 지역 변수를 바꾸지 않습니다. 같은 이벤트의 업데이트를 모아 처리하며, 서로 다른 클릭 이벤트까지 한 큐라고 생각하지 않습니다. Updater는 순수한 함수로 작성합니다.

본문의 ToggleSwitch 두 개

setIsToggled(prev => !prev);
A: false → true
B: false 유지

같은 컴포넌트를 두 위치에 사용해도 각 state는 독립적입니다. A의 버튼을 누르면 A의 다음 렌더만 자기 상태를 반전합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명같은 이벤트의 count 0에서 시작하는 서로 독립적인 두 경로는 각각 노드 4개·간선 3개로 구성된다. 왼쪽은 1로 교체 세 번 뒤 1, 오른쪽은 앞선 결과에 1씩 더해 0→1→2→3이다. 두 비교 경로 사이에는 간선이 없다. 현재 핸들러의 count는 두 경우 모두 0으로 유지된다.
전체 관계·조건 설명같은 이벤트의 count 0에서 시작하는 서로 독립적인 두 경로는 각각 노드 4개·간선 3개로 구성된다. 왼쪽은 1로 교체 세 번 뒤 1, 오른쪽은 앞선 결과에 1씩 더해 0→1→2→3이다. 두 비교 경로 사이에는 간선이 없다. 현재 핸들러의 count는 두 경우 모두 0으로 유지된다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
count0 snapshothandler count: 0 / 큐: 요청 전한 handler에서 세 요청을 만든다handler의 count 변수는 실행 중 유지된다
세 요청 비교교체 큐: 1,1,1 / updater: 0→1→2→3직접 값은 1/1/1 교체이고 updater는 앞 결과에 +1이다요청 값 계산과 큐 적용을 구분한다
다음 render직접 값: 1 / updater: 3교체 경로는 1, updater 경로는 3을 렌더한다두 독립 비교는 서로 순차 실행되는 큐가 아니다

state 업데이트 큐 — 상태 추적

count=0의 한 핸들러에서 교체 세 번은 최종 1, updater 세 번은 0→1→2→3이다. 핸들러 snapshot과 대기 큐/다음 렌더의 다른 값을 추적할 가치가 크다.

Updater는 큐의 직전 결과를 받아 다음 값을 계산합니다

STATE SNAPSHOT & QUEUE

Updater는 큐의 직전 결과를 받아 다음 값을 계산합니다

같은 이벤트 핸들러의 count가 0일 때를 비교합니다. setter 호출 뒤 그 핸들러의 count는 계속 0이며, React는 큐를 처리해 다음 렌더의 값을 정합니다.

값 교체와 Updater의 독립 큐 비교같은이벤트의count0에서시작하는서로독립인두4노드3간선경로다. 왼쪽은1로교체세번뒤1, 오른쪽은앞선결과에1씩더해0→1→2→3이다. 두비교경로사이간선은없다. 현재핸들러count는두경우모두0으로유지된다.값 전달 · 다음 1현재 count: 0같은 핸들러 시작1로 교체첫 setter 요청1로 교체둘째 setter 요청1로 교체 · 다음 1셋째 setter 요청Updater · 다음 3현재 count: 0같은 핸들러 시작0 + 1 → 1앞 결과를 받음1 + 1 → 2앞 결과를 받음2 + 1 → 3다음 렌더 값
값 교체와 Updater의 독립 큐 비교 · 모바일같은이벤트의count0에서시작하는서로독립인두4노드3간선경로다. 왼쪽은1로교체세번뒤1, 오른쪽은앞선결과에1씩더해0→1→2→3이다. 두비교경로사이간선은없다. 현재핸들러count는두경우모두0으로유지된다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.값 전달 · 다음 1현재 count: 0같은 핸들러 시작1로 교체첫 setter 요청1로 교체둘째 setter 요청1로 교체 · 다음 1셋째 setter 요청Updater · 다음 3현재 count: 0같은 핸들러 시작0 + 1 → 1앞 결과를 받음1 + 1 → 2앞 결과를 받음2 + 1 → 3다음 렌더 값

값 전달 세 번 · 다음 값 1

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

모두 같은 스냅샷 0을 읽어 1을 전달합니다.

큐: 1로 교체 → 1로 교체 → 1로 교체
다음 렌더 count: 1

Updater 세 번 · 다음 값 3

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

각 함수는 앞선 결과를 이어받아 계산합니다.

큐: 0 → 1 → 2 → 3
다음 렌더 count: 3

현재 핸들러와 다음 렌더

setter는 현재 지역 변수를 바꾸지 않습니다. 같은 이벤트의 업데이트를 모아 처리하며, 서로 다른 클릭 이벤트까지 한 큐라고 생각하지 않습니다. Updater는 순수한 함수로 작성합니다.

본문의 ToggleSwitch 두 개

setIsToggled(prev => !prev);
A: false → true
B: false 유지

같은 컴포넌트를 두 위치에 사용해도 각 state는 독립적입니다. A의 버튼을 누르면 A의 다음 렌더만 자기 상태를 반전합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명같은 이벤트의 count 0에서 시작하는 서로 독립적인 두 경로는 각각 노드 4개·간선 3개로 구성된다. 왼쪽은 1로 교체 세 번 뒤 1, 오른쪽은 앞선 결과에 1씩 더해 0→1→2→3이다. 두 비교 경로 사이에는 간선이 없다. 현재 핸들러의 count는 두 경우 모두 0으로 유지된다.
전체 관계·조건 설명같은 이벤트의 count 0에서 시작하는 서로 독립적인 두 경로는 각각 노드 4개·간선 3개로 구성된다. 왼쪽은 1로 교체 세 번 뒤 1, 오른쪽은 앞선 결과에 1씩 더해 0→1→2→3이다. 두 비교 경로 사이에는 간선이 없다. 현재 핸들러의 count는 두 경우 모두 0으로 유지된다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
count0 snapshothandler count: 0 / 큐: 요청 전한 handler에서 세 요청을 만든다handler의 count 변수는 실행 중 유지된다
세 요청 비교교체 큐: 1,1,1 / updater: 0→1→2→3직접 값은 1/1/1 교체이고 updater는 앞 결과에 +1이다요청 값 계산과 큐 적용을 구분한다
다음 render직접 값: 1 / updater: 3교체 경로는 1, updater 경로는 3을 렌더한다두 독립 비교는 서로 순차 실행되는 큐가 아니다

위 그림은 이 글의 뒤쪽 주의사항에서 다룰 state 갱신의 원리를 미리 보여 줍니다. 같은 이벤트 처리 함수에서 count가 0일 때 setter를 세 번 부르는 두 방식을 비교하며, 두 경로는 서로 이어지지 않습니다. 왼쪽처럼 setCount(count + 1)을 세 번 부르면 세 호출 모두 같은 스냅샷의 0을 읽어 “1로 교체”를 세 번 요청하므로 다음 렌더의 count는 1입니다. 오른쪽처럼 setCount(c => c + 1)을 세 번 부르면 각 함수가 앞선 결과를 이어받아 0, 1, 2, 3으로 계산하므로 다음 렌더의 count는 3입니다.

두 경우 모두 현재 이벤트 처리 함수 안의 count는 계속 0입니다. 그림의 설명처럼 setter는 현재의 지역 변수를 바꾸지 않고, 같은 이벤트에서 요청된 갱신을 모아 처리하며, 서로 다른 클릭 이벤트까지 하나의 큐로 묶지는 않습니다. 이렇게 setter에 넘기는 함수를 업데이터(updater)라고 부르며, 순수한 함수로 작성해야 합니다. 그림 아래쪽은 이 글의 실습에서 만들 ToggleSwitch 두 개를 보여 주는데, A의 버튼을 누르면 A만 false에서 true로 바뀌고 B는 false 그대로입니다. 같은 컴포넌트를 두 위치에 써도 각 state는 따로 있습니다.

useState 훅은 다음과 같이 씁니다.

import { useState } from 'react'; // (1) useState 훅 임포트

function MyCounter() {
  // (2) useState 호출: 배열 비구조화 할당으로 state 변수와 setter 함수 얻기
  const [count, setCount] = useState(0); // 초기값은 0

  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button> {/* (3) setter 함수를 이용해 state 변경 */}
    </div>
  );
}

하나씩 살펴봅니다. useState를 쓰려면 먼저 react 라이브러리에서 불러옵니다.

import { useState } from 'react';

이름 앞뒤의 중괄호는 react 패키지가 여러 값을 내보내는 가운데 useState라는 이름의 값만 골라 가져오는 이름 있는 가져오기(named import)입니다. useState를 호출하면 요소 두 개를 가진 배열을 돌려주며, 이 배열의 요소를 구조 분해 할당으로 꺼내 씁니다. 배열이므로 변수 이름은 위치로 정해지며, count와 setCount 대신 원하는 이름을 붙여도 됩니다.

const [state변수, state변경함수] = useState(초기값);

첫 번째 요소인 state 변수(예: count)는 현재 state의 값을 담고 있습니다. 이 값은 그 렌더의 스냅샷이며, 다음 렌더에서 새 값을 받습니다. 두 번째 요소인 setter(state 변경 함수, 예: setCount)는 state를 갱신하는 함수로, 새 state 값을 인자로 넘겨 호출합니다. state는 반드시 이 함수로만 바꿔야 합니다. 이 예제의 state 변수는 const라서 직접 다시 대입하면 오류가 나며, setter는 다음 렌더를 요청하는 역할을 합니다.

useState의 인자인 초기값(예: 0)은 그 state를 처음 만들 때만 쓰입니다. 다음 렌더에서 인자의 값이 바뀌어도 state가 저절로 다시 초기화되지는 않습니다. 또 함수를 인자로 넘기면 React가 그 함수를 초기화 함수로 호출하는 특별한 규칙이 있으므로, 이 글에서 쓰는 숫자나 불리언 초기값과 구별해 둡니다. 정리하면 const [count, setCount] = useState(0);은 count라는 state 변수를 만들어 초기값을 0으로 정하고, count를 바꿀 때는 setCount 함수를 쓰겠다는 뜻입니다.

state를 바꾸려면 두 번째 반환값인 setter를 호출하고, 새 state 값을 인자로 넘깁니다.

<button onClick={() => setCount(count + 1)}>증가</button>

버튼을 클릭하면 setCount가 호출되어, 현재 count에 1을 더한 값을 다음 state로 요청합니다. count가 바뀌었으므로 React는 MyCounter 컴포넌트를 다시 렌더링하고, 화면의 p 태그에 갱신된 count를 보여 줍니다. 처음 화면이 “현재 카운트: 0”이라면 한 번 누른 뒤에는 “현재 카운트: 1”이 됩니다.

다만 setter는 그 이벤트 처리 함수 안의 count 변수에 새 값을 즉시 대입하지 않습니다. 또 현재 값과 다음 값이 같으면 React가 갱신을 건너뛸 수 있으므로, setter를 부른다고 항상 화면을 다시 그린다고 보장되지는 않습니다(React 문서의 useState).


실습 예제: 토글 버튼 만들기

useState로 간단한 토글(toggle) 버튼을 만들어 봅니다. 버튼을 누를 때마다 글자가 켜짐과 꺼짐으로 번갈아 바뀌게 합니다. 먼저 src/components 폴더 안에 ToggleSwitch.jsx 파일을 만듭니다.

src/components/ToggleSwitch.jsx
import { useState } from 'react';

function ToggleSwitch() {
  // isToggled: 현재 상태 값 (초기값: false)
  // setIsToggled: isToggled를 변경할 때 사용하는 함수
  const [isToggled, setIsToggled] = useState(false);

  const handleToggle = () => {
    // setIsToggled 함수를 사용하여 isToggled의 현재 값을 반전
    setIsToggled(prev => !prev);
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', margin: '20px' }}>
      <h2>토글 스위치</h2>
      <p>
        현재 상태: <span style={{ fontWeight: 'bold', color: isToggled ? 'green' : 'red' }}>
          {isToggled ? '켜짐' : '꺼짐'}
        </span>
      </p>
      <button
        onClick={handleToggle}
        style={{
          padding: '10px 20px',
          fontSize: '16px',
          cursor: 'pointer',
          backgroundColor: isToggled ? '#4CAF50' : '#f44336', // 상태에 따라 버튼 색상 변경
          color: 'white',
          border: 'none',
          borderRadius: '5px'
        }}
      >
        {isToggled ? '끄기' : '켜기'}
      </button>
    </div>
  );
}

export default ToggleSwitch;

useState(false)로 isToggled라는 state를 만들고 초기값을 false로 정했습니다. handleToggle은 버튼을 클릭할 때 호출되는 함수로, setIsToggled(prev => !prev)를 써서 직전 상태를 기준으로 값을 뒤집습니다. 화면에서는 isToggled의 값에 따라 상태 글자(“켜짐”이나 “꺼짐”)와 버튼의 글자, 배경색이 바뀝니다.

이제 src/App.jsx를 열어 기존 내용을 고치거나 더해 ToggleSwitch 컴포넌트를 불러와 씁니다.

src/App.jsx
import './App.css';
import UserProfile from './components/UserProfile'; // 기존 UserProfile이 있다면 그대로 둡니다.
import ToggleSwitch from './components/ToggleSwitch'; // ToggleSwitch 컴포넌트 불러오기

function App() {
  // ... 기존 UserProfile 관련 코드 ...

  return (
    <div className="App">
      <h1>나의 React 앱</h1>

      {/* UserProfile 컴포넌트 (있다면) */}
      {/* <UserProfile name="김리액트" age={28} location="서울" /> */}

      {/* ToggleSwitch 컴포넌트 추가 */}
      <ToggleSwitch />
      <ToggleSwitch /> {/* 여러 번 재사용해도 각자 상태를 가집니다! */}

    </div>
  );
}

export default App;

브라우저를 보면 토글 스위치 두 개가 나타납니다. 버튼을 누를 때마다 글자와 버튼의 색이 바뀌는데, ToggleSwitch를 두 번 썼지만 각자 상태를 따로 관리한다는 점을 확인해 보세요. 새로 연 화면에서는 둘 다 “현재 상태: 꺼짐”과 “켜기” 버튼을 보여 주고, 첫 번째 버튼만 한 번 누르면 첫 번째는 “켜짐”과 “끄기”로 바뀌지만 두 번째는 “꺼짐”과 “켜기”를 그대로 유지합니다. 화면이 반영된 뒤 첫 번째 버튼을 다시 누르면 첫 번째만 원래 표시로 돌아옵니다.

같은 ToggleSwitch를 여러 번 렌더링해도 각 인스턴스는 자기만의 state를 따로 가지고 독립적으로 갱신됩니다. 다만 두 컴포넌트가 계속 같은 위치에 렌더되는 동안에만 그렇습니다. 컴포넌트를 화면 트리에서 없앴다가 다시 넣으면 새 초기값으로 시작하므로, 단순히 다시 렌더링되는 것과 없어졌다 다시 나타나는 것은 구별해야 합니다(React 문서의 state 보존과 초기화).


state 업데이트 시 주의사항

먼저 state를 직접 바꾸지 마세요. 이 예제의 count는 const로 선언했으므로 count = count + 1처럼 다시 대입하면 상수에 대입한다는 자바스크립트 오류가 먼저 납니다. 지역 변수에 값을 대입하는 것은 React에 다음 렌더를 요청하는 일이 아닙니다. 객체나 배열을 state로 쓸 때도 기존의 내용을 직접 고치지 말고, 새 값을 만들어 setter에 넘깁니다.

// ❌ 잘못된 예시: state를 직접 변경
const [count, setCount] = useState(0);
// count = count + 1; // 이렇게 하면 안 됩니다!

// ⭕ 올바른 예시: setter 함수 사용
setCount(count + 1);

다음으로 state의 갱신은 바로 일어나지 않습니다. 같은 이벤트 처리 함수에서 setCount(count + 1)을 부른 직후 console.log(count)를 실행하면, 그 렌더의 기존 값이 출력됩니다. 그래서 이전 state 값을 바탕으로 새 state를 계산해야 한다면, setter에 업데이터를 넘기는 편이 안전합니다.

// 이전 state 값에 기반하여 업데이트할 때
setCount(prevCount => prevCount + 1);

prevCount에는 React가 갱신 큐를 처리할 때 앞선 갱신까지 적용한 값이 들어옵니다. 예를 들어 같은 클릭 처리 함수에서 현재 count가 2일 때 값을 두 번 넘기면, 두 호출 모두 현재 스냅샷의 2를 읽어 “3으로 교체”를 요청합니다. 반면 업데이터를 두 번 넘기면 큐가 앞의 계산 결과를 다음 함수에 넘겨 2, 3, 4로 이어집니다. 이것은 한 처리 함수 안의 두 호출이며, 서로 다른 두 번의 클릭과는 다른 상황입니다.

// 같은 핸들러, 현재 count = 2
setCount(count + 1);
setCount(count + 1); // 다음 렌더: 3

// 별도 비교: 역시 현재 count = 2에서 시작
setCount(c => c + 1);
setCount(c => c + 1); // 큐 계산 2 → 3 → 4, 다음 렌더: 4

그림의 count = 0에서 세 번 호출하는 비교도 같은 원리입니다. 업데이터는 넘겨받은 값으로 다음 값만 계산하도록 작성하고, 그 안에서 알림이나 네트워크 요청 같은 부수 효과를 실행하지 않습니다(React 문서의 state 갱신 큐).

더 깊이: React 18에서 넓어진 자동 배치

같은 처리 함수에서 요청한 여러 갱신을 모아 한 번만 다시 렌더링하는 것을 배치(batching)라고 부릅니다. React 17까지는 React의 이벤트 처리 함수 안에서 일어난 갱신만 묶었기 때문에, setTimeout이나 Promise의 콜백, addEventListener로 직접 붙인 처리 함수 안에서 setter를 두 번 부르면 갱신마다 따로 렌더링했습니다. React 18은 createRoot로 만든 루트에서 이런 갱신도 모두 묶는 자동 배치(automatic batching)를 도입했습니다(React 18 업그레이드 안내). 그래서 예전 자료에서 타이머 안의 setter 호출마다 렌더링이 한 번씩 일어난다고 설명했다면, 지금의 React에서는 한 번으로 줄어든다고 읽어야 합니다.

드물게 갱신을 DOM에 바로 반영한 뒤 그 DOM을 읽어야 한다면, react-dom의 flushSync로 콜백 안의 갱신을 배치에서 빼내 동기적으로 처리하게 할 수 있습니다. 다만 React 문서는 flushSync가 성능을 크게 떨어뜨릴 수 있고, 대기 중인 Suspense가 fallback을 보여 주게 만들거나 대기 중인 Effect를 먼저 실행할 수도 있다고 안내합니다. 그래서 스크롤 위치나 포커스처럼 갱신 직후의 DOM이 꼭 필요한 경우에만 씁니다.


정리

  • state는 컴포넌트 안에서 관리하며 시간에 따라 바뀌는 데이터이고, setter로 갱신을 요청하면 React가 다시 렌더링합니다.
  • useState(초기값)은 [현재 값, setter]를 돌려주며, 초기값은 처음 만들 때만 쓰입니다.
  • 훅은 컴포넌트의 최상위에서만 호출하며, 조건문이나 반복문 안에서 호출하지 않습니다.
  • setter는 현재 처리 함수의 변수를 바꾸지 않으며, 같은 이벤트의 갱신을 모아 다음 렌더의 값을 정합니다.
  • 이전 값에 따라 계산할 때는 setCount(c => c + 1)처럼 업데이터를 넘기고, 업데이터는 순수 함수로 작성합니다.
  • 같은 컴포넌트를 여러 위치에 써도 각 위치의 state는 따로 있으며, 화면에서 없어졌다 다시 나타나면 초기값으로 시작합니다.

연습 문제

1. 값 교체 뒤의 업데이터

현재 count가 2인 한 처리 함수에서 setCount(count + 4)를 부르고 이어서 setCount(c => c * 2)를 부릅니다. 바로 뒤의 console.log(count)가 출력하는 값과 다음 렌더의 값은 각각 무엇일까요?

해설 보기

현재 처리 함수의 count는 2이므로 로그는 2입니다. 큐는 먼저 6으로 교체한 뒤 그 결과에 2를 곱하므로, 다음 렌더의 값은 12입니다.

2. 두 토글 스위치

두 ToggleSwitch가 모두 꺼짐에서 시작합니다. 첫 번째 스위치를 두 번, 두 번째 스위치를 한 번 누르며, 클릭할 때마다 화면이 반영된 뒤에 다음 클릭을 한다고 가정합니다. 두 스위치의 최종 상태 글자와 버튼 문구는 무엇일까요?

해설 보기

첫 번째는 꺼짐에서 켜짐, 다시 꺼짐이 되어 “꺼짐”과 “켜기” 버튼을 보여 줍니다. 두 번째는 꺼짐에서 켜짐이 되어 “켜짐”과 “끄기” 버튼을 보여 줍니다. 두 스위치는 서로 다른 위치의 state를 가지므로, 같은 함수 정의를 쓴다고 상태가 하나로 합쳐지지 않습니다.

3. 세 요청을 섞은 Counter

count가 2에서 시작하는 Counter를 만듭니다. 한 번의 클릭에서 업데이터로 1을 더하고, 현재 스냅샷의 count에 4를 더한 값으로 교체한 뒤, 마지막으로 업데이터로 2배를 만듭니다. 처리 함수 끝의 로그와 다음 화면의 count를 예측한 뒤 코드를 완성해 보세요.

해설 보기

아래 코드를 학습 프로젝트의 src/App.jsx로 씁니다. 서버 요청이나 타이머는 없습니다.

src/App.jsx: 혼합 큐 Counter 정답
import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(2);

  function handleClick() {
    setCount(c => c + 1);
    setCount(count + 4);
    setCount(c => c * 2);
    console.log(count);
  }

  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={handleClick}>세 요청 보내기</button>
    </div>
  );
}

첫 클릭의 로그는 2입니다. 큐는 2에서 3이 되고, 6으로 교체된 뒤, 12가 되므로 다음 화면은 “현재 카운트: 12”입니다. 가운데 요청은 앞 업데이터의 결과 3에 4를 더하는 것이 아니라, 처리 함수의 스냅샷 2에 4를 더해 미리 계산한 6으로 교체하는 요청입니다. 값 교체와 업데이터를 섞는 규칙은 React 문서의 state 갱신 큐에 나옵니다.

4. 두 번째 클릭

3번의 첫 클릭이 반영된 뒤 같은 버튼을 한 번 더 누릅니다. 두 번째 처리 함수가 읽는 count와 다음 렌더의 값을 계산해 보세요.

해설 보기

두 번째 클릭은 새 렌더에서 만들어진 처리 함수를 쓰므로 로그는 12입니다. 큐는 12에서 13이 되고, 16으로 교체된 뒤, 32가 되므로 다음 화면은 32입니다. 첫 클릭의 스냅샷 2를 계속 쓰는 것으로 계산하지 않습니다(React 문서의 스냅샷으로서의 state).

5. 한 번에 두 번 뒤집기

원래 ToggleSwitch의 handleToggle 안에서 setIsToggled(prev => !prev)를 두 번 연속 호출하도록 바꾸는 실험을 합니다. false에서 한 번 클릭한 뒤의 최종 표시는 무엇일까요? 업데이터 안에 console.log나 알림을 넣어 그 호출 횟수를 클릭 수로 세면 안 되는 이유도 적어 보세요.

해설 보기

한 클릭의 큐는 false에서 true, 다시 false가 되므로 최종 표시는 “꺼짐”과 “켜기”입니다. 중간의 true는 큐의 계산값일 뿐, 이 처리 함수에서 켜짐 화면과 꺼짐 화면이 차례로 모두 그려진다는 보장은 없습니다.

업데이터는 받은 상태에서 다음 값만 계산해야 합니다. 개발 모드의 Strict Mode는 순수성을 확인하려고 업데이터를 한 번 더 호출할 수 있으므로, 그 안의 부수 효과가 실행된 횟수를 사용자의 클릭 수로 해석하면 안 됩니다(React 문서의 useState 문제 해결).

다음 글 이벤트 처리에서는 클릭과 입력 같은 사용자의 동작을 받아 state를 바꾸는 React의 이벤트 처리 방법을 살펴봅니다.