리액트 기초 강좌 #5 State와 useState
지난 시간에는 컴포넌트와 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)**의 하나입니다.
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 }); // 새 객체를 만들어 전달
}스프레드 연산자(...)로 기존 값을 펼친 뒤 변경할 부분만 덮어쓰는 패턴이 가장 흔합니다.
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 이벤트 핸들링"에서는 리액트의 이벤트 처리 방식을 본격적으로 살펴보고, 이벤트 객체에서 정보를 꺼내 쓰는 방법까지 알아보도록 하겠습니다.