목차
5 장

State와 useState

리액트가 다시 그리는 단위로서의 state. useState의 정확한 모델과 함수형 업데이트, 객체 상태 갱신 패턴을 살펴봅니다.

4장에서 컴포넌트와 props를 배웠습니다. 그런데 만든 컴포넌트는 모두 정적이었습니다. 한 번 그려지면 다시는 모습이 바뀌지 않았습니다. 실제 앱은 사용자가 버튼을 누르거나 입력을 하거나 데이터가 도착하면 화면이 갱신되어야 합니다. 본 챕터에서는 컴포넌트가 변할 수 있는 데이터를 다루는 방법인 state를 배우겠습니다.

useState는 이 책 전체에서 가장 자주 만날 도구입니다. 18장 (hooks 타이핑)에서 TypeScript로 한 번 더 다듬고, 24장 (Server vs Client Components)에서는 어떤 컴포넌트에서 useState를 쓸 수 있고 어디서는 쓸 수 없는지의 경계를 다시 짚겠습니다.

왜 그냥 변수로는 안 되나 #

가장 먼저 떠오르는 생각은 “그냥 변수 값을 바꾸면 되지 않나?” 일 겁니다. 한번 시도해 볼까요?

src/App.jsx
function App() {
  let count = 0;

  function handleClick() {
    count = count + 1;
    console.log('count:', count);
  }

  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

export default App;

버튼을 클릭하면 콘솔에는 count: 1, count: 2, count: 3처럼 값이 잘 증가합니다. 그런데 화면의 숫자는 그대로 0입니다. 왜 그럴까요?

리액트는 화면을 그릴 때 컴포넌트 함수를 한 번 실행하고, 그 결과를 화면에 반영합니다. 일반 변수의 값을 바꿔도 리액트는 “다시 그려야 한다"는 신호를 받지 못해 화면을 갱신하지 않습니다. 게다가 컴포넌트 함수는 다시 호출될 때마다 let count = 0이 처음부터 다시 실행되므로, 변경된 값은 다음 렌더링에 살아남지도 못합니다.

리액트가 화면을 다시 그리게 만들면서 그 값이 다음 렌더링에서도 유지되도록 하려면 state라는 특별한 저장소를 써야 합니다.

useState 훅 #

state는 useState라는 함수를 호출해 만듭니다. 이 함수는 리액트가 제공하는 **훅 (Hook)**의 하나입니다.

노트
**훅 (Hook)**은 함수 컴포넌트 안에서 리액트의 기능을 쓸 수 있게 해 주는 특수 함수입니다. 이름이 모두 use로 시작합니다(useState, useEffect, useContext 등). 훅의 깊은 이야기는 13장 (커스텀 훅)에서 다루고, 지금은 “리액트가 제공하는 특별한 함수” 정도로 이해하시면 됩니다.

useState를 사용해 카운터를 다시 만들어 봅니다.

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

function App() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

export default App;

이번에는 버튼을 누르면 화면의 숫자가 진짜로 증가합니다.

useState 들여다보기 #

위 코드를 한 줄씩 뜯어보겠습니다.

import { useState } from 'react';

react 패키지에서 useState를 가져옵니다. 훅을 사용하려면 항상 import 합니다.

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

useState(0)을 호출하면 길이 2짜리 배열이 반환됩니다. 자바스크립트의 구조분해 할당으로 두 값을 한꺼번에 받습니다.

  • 첫 번째 count현재 state 값. 처음에는 useState에 넣은 초기값(0)입니다.
  • 두 번째 setCountstate를 변경하는 함수. 이 함수를 호출해야 리액트가 화면을 다시 그립니다.

이름은 마음대로 지어도 되지만 관례상 [값, set값] 패턴으로 짓습니다. name이라면 [name, setName], isOpen이라면 [isOpen, setIsOpen] 같은 식입니다.

setCount(count + 1);

setCount에 새 값을 넣어 호출하면, 리액트는 (1) state를 업데이트하고 (2) 컴포넌트를 다시 렌더링합니다. 다시 렌더링되면 컴포넌트 함수가 처음부터 다시 실행되고, 이번에는 useState(0)이 새로 갱신된 값(1)을 돌려줍니다.

state가 바뀌면 무슨 일이 일어나는가 #

이 그림을 머릿속에 잘 그려 두면 앞으로 리액트 코드가 훨씬 잘 읽힙니다.

  1. 사용자가 버튼을 클릭
  2. handleClick 함수가 실행됨 → setCount(1) 호출
  3. 리액트가 state를 1로 업데이트하고 컴포넌트를 다시 렌더링
  4. App 함수가 처음부터 다시 실행됨
  5. useState(0)이 이번에는 [1, setCount]를 반환
  6. 새 JSX (<p>현재 카운트: 1</p>)가 만들어짐
  7. 리액트가 이전 화면과 비교해 변경된 부분만 실제 DOM에 반영

state가 바뀔 때마다 컴포넌트 함수 전체가 다시 실행된다는 점이 중요합니다. 그래서 let count = 0처럼 컴포넌트 안에서 선언한 일반 변수는 매번 초기화되어 값이 유지되지 않는 것입니다. state는 리액트 내부 어딘가에 별도로 보관되어, 다음 렌더링에서도 살아남습니다.

다양한 타입의 state #

state 값은 숫자뿐 아니라 어떤 자바스크립트 값이든 될 수 있습니다.

다양한 state 예시
const [name, setName] = useState('');                    // 문자열
const [isOpen, setIsOpen] = useState(false);             // 불리언
const [items, setItems] = useState([]);                  // 배열
const [user, setUser] = useState({ name: '', age: 0 });  // 객체
const [selected, setSelected] = useState(null);          // null

18장 (hooks 타이핑)에서 보겠지만, TypeScript에서는 초기값으로부터 타입이 자동 추론됩니다. useState('')string, useState(0)이면 number가 추론됩니다.

state는 직접 수정하지 마세요 #

가장 자주 하는 실수입니다. 다음 코드는 동작하지 않습니다.

잘못된 예
const [count, setCount] = useState(0);

function handleClick() {
  count = count + 1;  // 🚫 직접 수정
}
잘못된 예 (배열)
const [items, setItems] = useState(['사과']);

function addItem() {
  items.push('바나나');  // 🚫 배열을 직접 변경
  setItems(items);
}

state는 반드시 set 함수를 통해 새 값을 전달해 변경해야 합니다. 배열이나 객체의 경우에는 새 배열 / 새 객체를 만들어 넘기는 것이 원칙입니다.

올바른 예 (배열 추가)
const [items, setItems] = useState(['사과']);

function addItem() {
  setItems([...items, '바나나']);  // 새 배열을 만들어 전달
}
올바른 예 (객체 일부 수정)
const [user, setUser] = useState({ name: '철수', age: 30 });

function birthday() {
  setUser({ ...user, age: user.age + 1 });  // 새 객체를 만들어 전달
}

스프레드 연산자 (...)로 기존 값을 펼친 뒤 변경할 부분만 덮어쓰는 패턴이 가장 흔합니다.

노트
“왜 굳이 새 배열 / 객체를 만들어야 하나요?” 리액트는 state가 바뀌었는지 판단할 때 참조(reference)가 다른지를 확인합니다. items.push(...)는 같은 배열의 내용만 바꿀 뿐 참조는 그대로라서, 리액트는 변화가 없다고 판단하고 다시 렌더링하지 않습니다. 새 배열 / 객체를 만들어 넘겨야 리액트가 “어, 다른 값이네” 하고 화면을 갱신합니다.

함수형 업데이트 #

state 값을 이전 값을 기반으로 업데이트할 때는 set 함수에 함수를 전달 할 수 있습니다.

src/App.jsx
function handleClick() {
  setCount(prev => prev + 1);
}

setCount(count + 1)과 거의 같지만, 이전 값을 안전하게 받아오기 때문에 여러 번 연속으로 호출 해야 할 때 정확히 동작합니다.

문제가 되는 패턴
function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

이 코드는 한 번 클릭에 카운트를 3 증가시킬 것 같지만, 실제로는 1만 증가합니다. 세 호출 모두 같은 count 값을 보고 있기 때문에 모두 count + 1을 시도합니다.

함수형 업데이트로 안전하게
function handleClick() {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
}

이렇게 쓰면 각 호출이 직전 결과를 받아 처리하므로 카운트가 정확히 3 증가합니다. 평소에는 setCount(count + 1)로도 충분하지만, “이전 값을 기준으로 업데이트한다"는 의미가 명확한 함수형 업데이트가 더 안전한 기본 패턴입니다. 이 책에서는 가능하면 함수형 업데이트를 사용합니다.

여러 개의 state #

useState는 한 컴포넌트 안에서 몇 번이든 호출할 수 있습니다.

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

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  // ... 입력 처리 로직 ...
}

성격이 다른 값들은 각각 별도의 state로 관리하는 것이 일반적입니다. 한 객체에 모아 담을 수도 있지만 변경할 때마다 스프레드로 펼쳐야 해서 코드가 길어지기 때문에, 단순한 값들은 따로 두는 쪽이 편합니다. 9장 (폼 다루기)에서 둘 다 비교해 보겠습니다.

직접 해보기 #

카운터 컴포넌트를 만들어 보겠습니다. +1, -1, 리셋 버튼이 있는 컴포넌트입니다.

src/Counter.jsx를 새로 만듭니다.

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

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>카운트: {count}</h2>
      <button onClick={() => setCount(prev => prev + 1)}>+1</button>
      <button onClick={() => setCount(prev => prev - 1)}>-1</button>
      <button onClick={() => setCount(0)}>리셋</button>
    </div>
  );
}

export default Counter;

src/App.jsx에서 가져다 씁니다.

src/App.jsx
import Counter from './Counter';

function App() {
  return (
    <>
      <h1>카운터 데모</h1>
      <Counter />
      <Counter />
    </>
  );
}

export default App;

흥미로운 점이 하나 있습니다. <Counter />를 두 번 사용했는데 각 카운터가 자기만의 카운트를 가지고 독립적으로 동작합니다. 한쪽에서 +1을 눌러도 다른 쪽 숫자는 그대로입니다. state는 컴포넌트의 인스턴스 (instance) 별로 따로 보관되기 때문입니다.

연습문제 #

  1. Counter에 새 버튼 +10을 추가해 한 번 누르면 10이 한 번에 증가하도록 만들어 보세요. 함수형 업데이트(setCount(prev => prev + 10)) 패턴을 씁니다.
  2. Counterminmax prop을 추가하고, 카운트가 min 아래로 내려가거나 max 위로 올라가지 못하도록 막아 보세요. <Counter min={0} max={10} />처럼 호출하면 0 미만, 10 초과는 불가능해야 합니다. 함수형 업데이트 안에서 Math.max · Math.min으로 처리합니다.
  3. 객체 state를 다루는 연습. User 컴포넌트를 만들고 useState({ name: '철수', age: 30 })로 초기값을 두세요. “이름 바꾸기” 버튼은 prev => ({ ...prev, name: '영희' })로, “나이 +1” 버튼은 prev => ({ ...prev, age: prev.age + 1 })로 처리합니다. 스프레드로 새 객체를 만드는 패턴이 손에 익을 때까지 반복해 보세요.

한 줄 요약: 일반 변수로는 화면을 갱신할 수 없다. useState[값, set값]을 받아 set 함수로만 변경한다. 배열 · 객체는 새 값을 만들어 넘긴다 ([...arr, x], { ...obj, k: v }). 이전 값을 기반으로 갱신할 때는 함수형 업데이트 (setX(prev => ...))가 안전한 기본이다.

다음 챕터 #

지금까지 사용한 onClick 같은 이벤트 핸들러는 그냥 가져다 썼을 뿐 자세히 다루지 않았습니다. 다음 6장 이벤트 핸들링에서는 리액트의 이벤트 처리 방식을 본격적으로 살펴보고, 이벤트 객체에서 정보를 꺼내 쓰는 방법, 그리고 19장 (이벤트와 폼 타이핑)으로의 다리까지 짚겠습니다.

X