React 기초4편

State와 useState — 컴포넌트가 스스로 기억하는 값

React의 State를 Props와 대비해 정리합니다. 일반 변수로는 왜 안 되는지, useState를 왜 const로 받는지, 값을 반드시 setter로 바꿔야 하는 이유까지 짚습니다.

지난 글에서 Props는 바깥(부모)에서 받아 바꿀 수 없는 값이라고 했습니다. 그러면서 자연스러운 질문을 하나 남겼죠. 컴포넌트가 스스로 들고 있으면서 시간에 따라 바뀌는 값은 어떻게 다룰까? 그 답이 오늘의 주제, State(상태)입니다.

버튼을 누를 때마다 올라가는 카운터, 입력창에 타이핑되는 글자, 열리고 닫히는 메뉴 — 이렇게 사용자와 상호작용하며 변하는 값이 전부 State입니다.


왜 일반 변수로는 안 될까

“값이 바뀌는 거면 그냥 변수 쓰면 되지 않나?” 싶습니다. 그런데 컴포넌트 안에서 평범한 변수로는 두 가지가 안 됩니다.

function Counter() {
  let count = 0;
  return <button onClick={() => { count += 1; }}>{count}</button>;
}

이 버튼을 눌러도 화면의 숫자는 0에서 꿈쩍도 하지 않습니다. 이유는 두 가지입니다.

  1. 바뀌어도 화면이 다시 그려지지 않는다. 1편에서 React는 “상태가 바뀌면 화면을 다시 그린다”고 했는데, 평범한 변수는 React가 지켜보지 않아서 바뀐 걸 모릅니다.
  2. 다시 그려지면 값이 초기화된다. 설령 어떻게 다시 그려진들, 컴포넌트 함수가 처음부터 다시 실행되면서 let count = 0도 다시 실행돼 0으로 돌아갑니다.

즉 컴포넌트에는 “리렌더링을 넘어 값을 기억하고, 그 값이 바뀌면 화면도 다시 그리는” 특별한 변수가 필요합니다. 그게 State이고, 이를 만드는 도구가 useState입니다.


useState 사용법

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

useState는 초기값을 인자로 받아 길이 2짜리 배열을 돌려줍니다. 이걸 구조 분해로 받는데,

  • 첫 번째(count): 현재 상태 값
  • 두 번째(setCount): 그 값을 바꾸는 전용 함수(setter)

이름은 자유지만 관례상 x, setX 형태로 짝지어 씁니다. 이제 버튼을 누르면 setCount가 상태를 바꾸고, 그에 따라 컴포넌트가 다시 그려지면서 새 숫자가 화면에 나타납니다.

use로 시작하는 이런 특수 함수들을 훅(Hook)이라고 부릅니다. State, 사이드 이펙트 등 React의 기능에 함수 컴포넌트가 ‘갈고리를 걸어’ 쓰는 것이라고 이해하면 됩니다. 이 시리즈의 상당 부분이 이 훅들에 대한 이야기입니다.


let이 아니라 const로 받을까

값이 바뀌는데 const로 받는 게 이상해 보입니다. “바뀔 값이니 let이어야 하지 않나?” 하지만 여기엔 React의 렌더링 방식에 대한 오해가 숨어 있습니다.

핵심은 이것입니다. count는 “한 번의 렌더링” 안에서는 절대 바뀌지 않습니다. 상태가 바뀐다는 건, 그 값을 그 자리에서 고치는 게 아니라, 컴포넌트 함수를 처음부터 새로 실행한다는 뜻입니다. 그 새 실행에서 useState가 이번엔 갱신된 값을 돌려주고, const count는 그 새 값으로 새로 바인딩됩니다.

그러니까 흐름은 “하나의 count를 계속 고쳐 쓴다”가 아니라, “렌더링마다 그 순간의 count가 새로 만들어진다”입니다. 각 렌더링 안에서 count는 고정된 스냅샷이므로 const가 정확한 표현입니다. 오히려 let으로 두고 직접 count = ...처럼 고치면, React는 그 변경을 전혀 알지 못한 채 다음 렌더링에서 덮어써 버려 혼란만 생깁니다.


값은 반드시 setter로 바꾼다

방금 이야기에서 가장 중요한 실전 규칙이 나옵니다. 상태는 절대 직접 고치지 말고, 언제나 setter로 바꿉니다.

count = count + 1;        // ❌ React가 모른다 → 화면 안 바뀜
setCount(count + 1);      // ✅ 상태 변경 + 리렌더링 예약

setCount는 단순히 값만 바꾸는 게 아니라 “이 컴포넌트를 다시 그려라”라고 React에 예약을 겁니다. 이 한 줄이 값 변경과 화면 갱신을 하나로 묶어 주는, State의 존재 이유 그 자체입니다.

객체나 배열 상태도 마찬가지입니다. 안을 직접 수정(user.name = ...)하지 말고, 새 객체를 만들어 setter에 넘깁니다.

setUser({ ...user, name: '새 이름' });

이전 값을 기반으로 바꿀 땐 함수로

한 가지 실전 팁. 이전 상태를 토대로 다음 값을 정할 때는, 값 대신 함수를 넘기는 편이 안전합니다.

setCount(prev => prev + 1);

setCount에 함수를 넣으면 React가 가장 최신 상태prev로 넣어 줍니다. 버튼을 빠르게 여러 번 눌러 상태 변경이 몰릴 때도 값이 꼬이지 않아, “왜 한 번밖에 안 오르지?” 같은 버그를 예방할 수 있습니다.


정리하면, State는 리렌더링을 넘어 유지되고, 바뀌면 화면을 다시 그리는 값이며, 그 변경은 언제나 setter를 통합니다. 이제 “상태가 바뀌면 화면이 다시 그려진다”는 흐름이 손에 잡혔을 겁니다.

그런데 상태가 바뀔 때 화면을 그리는 것 말고 다른 일을 하고 싶다면 어떻게 할까요? 예를 들어 검색어(상태)가 바뀔 때마다 서버에 요청을 보내야 한다면요. 그 ‘화면 밖의 일’을 다루는 도구가 다음 글의 useEffect입니다.