지난 시간에는 컴포넌트와 props를 배웠습니다. 그런데 우리가 만든 컴포넌트는 모두 정적이었습니다. 한 번 화면에 그려지면 다시는 모습이 바뀌지 않았죠. 실제 앱은 사용자가 버튼을 누르거나, 입력을 하거나, 데이터가 도착하면 화면이 갱신되어야 합니다. 이번 시간에는 컴포넌트가 변할 수 있는 데이터를 다루는 방법인 state를 배워보겠습니다.
왜 그냥 변수로는 안 되나?
가장 먼저 떠오르는 생각은 "그냥 변수 값을 바꾸면 되지 않나?"일 겁니다. 한 번 시도해볼까요?
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 ...). 훅에 대한 자세한 내용은 시리즈 후반부에서 다루지만, 지금은 "리액트가 제공하는 특별한 함수" 정도로 이해하시면 됩니다.
useState를 사용해 카운터를 다시 만들어봅니다.
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)입니다. - 두 번째
setCount: state를 변경하는 함수. 이 함수를 호출해야 리액트가 화면을 다시 그립니다.
이름은 마음대로 지어도 되지만 관례상 [값, set값] 패턴으로 짓습니다. name이라면 [name, setName], isOpen이라면 [isOpen, setIsOpen]처럼요.
setCount(count + 1);setCount에 새 값을 넣어 호출하면, 리액트는 (1) state를 업데이트하고 (2) 컴포넌트를 다시 렌더링합니다. 다시 렌더링되면 컴포넌트 함수가 처음부터 다시 실행되고, 이번에는 useState(0)이 새로 갱신된 값(1)을 돌려줍니다.
state가 바뀌면 무슨 일이 일어나는가
이 그림을 머릿속에 잘 그려두면 앞으로 리액트 코드가 훨씬 잘 읽힙니다.
- 사용자가 버튼을 클릭
handleClick함수가 실행됨 →setCount(1)호출- 리액트가 state를
1로 업데이트하고 컴포넌트를 다시 렌더링 App함수가 처음부터 다시 실행됨useState(0)이 이번에는[1, setCount]를 반환- 새로운 JSX (
<p>현재 카운트: 1</p>)가 만들어짐 - 리액트가 이전 화면과 비교해서 변경된 부분만 실제 DOM에 반영
state가 바뀔 때마다 컴포넌트 함수 전체가 다시 실행된다는 점이 중요합니다. 그래서 let count = 0처럼 컴포넌트 안에서 선언한 일반 변수는 매번 초기화되어 값이 유지되지 않는 것입니다. 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); // nullstate는 절대 직접 수정하지 마세요
가장 자주 하는 실수입니다. 다음 코드는 동작하지 않습니다.
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 함수에 함수를 전달할 수 있습니다.
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는 한 컴포넌트 안에서 몇 번이든 호출할 수 있습니다.
import { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// ... 입력 처리 로직 ...
}성격이 다른 값들은 각각 별도의 state로 관리하는 것이 일반적입니다. 한 객체에 모아 담을 수도 있지만, 변경할 때마다 스프레드로 펼쳐야 해서 코드가 길어지기 때문에 단순한 값들은 따로 두는 쪽이 편합니다.
직접 해보기
카운터 컴포넌트를 만들어보겠습니다. +1, -1, 리셋 버튼이 있는 컴포넌트입니다.
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에서 가져다 씁니다.
import Counter from './Counter';
function App() {
return (
<>
<h1>카운터 데모</h1>
<Counter />
<Counter />
</>
);
}
export default App;흥미로운 점이 하나 있습니다. <Counter />를 두 번 사용했는데, 각 카운터가 자기만의 카운트를 가지고 독립적으로 동작합니다. 한쪽에서 +1을 눌러도 다른 쪽 숫자는 그대로입니다. state는 컴포넌트의 인스턴스(instance)별로 따로 보관되기 때문입니다.
마무리
이번 글에서는 컴포넌트가 변하는 데이터를 다루는 도구인 state와 useState 훅을 배웠습니다. 핵심을 정리하면:
- 일반 변수로는 화면을 갱신할 수 없다 →
useState를 사용한다 const [값, set값] = useState(초기값)패턴- state는 직접 수정하지 말고 반드시 set 함수로 변경한다
- 배열/객체는 새 값을 만들어 넘겨야 한다 (
[...arr, x],{ ...obj, k: v }) - 이전 값을 기반으로 갱신할 때는 함수형 업데이트(
setX(prev => ...))가 안전하다
지금까지 사용한 onClick 같은 이벤트 핸들러는 그냥 가져다 썼을 뿐 자세히 다루지 않았습니다. 다음 글인 "리액트 기초 강좌 #6 이벤트 핸들링"에서는 리액트의 이벤트 처리 방식을 본격적으로 살펴보고, 이벤트 객체에서 정보를 꺼내 쓰는 방법까지 알아보도록 하겠습니다.