props가 "부모가 준 값"이었다면, state는 컴포넌트가 스스로 기억하고 바꾸는 값이에요. 이 값이 바뀌면 리액트가 화면을 다시 그려줍니다. 이번 장에서 useState 하나로 그 마법을 익혀요.
먼저 "상태(state)"가 뭔지 일상으로 감부터 잡을게요. 상태는 그냥 "지금 어떤지"예요 — 그리고 시간이 지나면 바뀌죠.
리액트 화면도 똑같아요. 사용자가 뭔가 할 때마다 "지금 값"이 바뀌고, 리액트는 그에 맞춰 화면을 다시 그려요. 흐름은 이거예요:
let count = 0처럼 만든 값은, 값을 바꿔도 화면이 다시 그려지지 않고, 컴포넌트가 다시 실행되면 0으로 초기화돼요. 즉 "기억"이 안 돼요.useState로 만든 값은 리렌더링이 일어나도 값이 유지되고, setter로 바꾸면 리액트가 화면을 다시 그려줘요.
그래서 "화면에 보여야 하고, 바뀌면 화면도 같이 바뀌어야 하는 값"은 지역변수가 아니라 state로 둬야 해요.
// ❌ 일반 지역변수 — 바꿔도 화면이 안 변하고, 다시 실행되면 0으로 돌아감
function Counter() {
let count = 0;
return ;
// 눌러도 화면은 계속 0 — 리렌더가 안 일어나기 때문
}
onClick={() => ...}는 "이 버튼을 누르면 화살표 뒤의 코드를 실행해줘"라는 뜻이에요. () =>(화살표 함수)와 이벤트는 CH05에서 제대로 배우니, 지금은 "누르면 실행된다" 정도로만 보면 충분해요.count++처럼 값을 직접 바꾸면 ②(변경 감지)가 안 일어나서 화면이 그대로예요. 그리고 리액트는 ④에서 바뀐 부분만 골라 실제 화면(DOM)에 반영해요 — 이렇게 달라진 곳만 찾아 고치는 방식을 가상 DOM(Virtual DOM)이라고 해요(자세힌 뒤 장에서).
함수 컴포넌트에서 state는 useState라는 훅(Hook)으로 만들어요. 훅은 리액트가 미리 만들어 둔 특별한 함수예요 — 이름이 전부 use로 시작한다는 게 특징이죠(useState, useEffect…). useState는 [현재 값, 값을 바꾸는 함수] 두 개가 담긴 배열을 돌려줘요. 이걸 배열 구조분해(const [a, b] = 배열처럼 배열의 0번·1번을 한 번에 꺼내는 문법)로 받습니다.
set을 붙여요 — setCount, setName처럼요.| Hook | 하는 일 |
|---|---|
useState | 컴포넌트가 값을 "기억"하게 해줌 (state) |
useEffect | 화면이 그려진 후 특정 코드를 실행하게 해줌 (부수 효과) |
useContext | 여러 컴포넌트가 데이터를 공유하게 해줌 |
use로 시작합니다. 이건 React가 강제하는 규칙이자 관례예요.
useState()
useEffect()
useContext()
function Counter() {
const [count, setCount] = useState(0); // useState라는 Hook을 "사용"
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}useState(0)을 호출하는 순간, "이 컴포넌트가 count라는 값을 하나 기억하고 있어야 해"라고 React에게 알려주는 거예요. 이게 바로 Hook이 하는 일입니다.import { useState } from 'react';
function Counter() {
// useState(초기값) → [현재 값, 바꾸는 함수]
const [count, setCount] = useState(0);
// ↑0번 ↑1번 ↑초기값
return (
);
}
useState(0) — 괄호 안 0은 초기값이에요. 컴포넌트가 처음 그려질 때 딱 한 번만 쓰여요.count (0번) — 지금 현재 값. 화면에 그대로 씁니다.setCount (1번) — 값을 바꾸는 변경 함수(setter). 이걸 호출해야 리렌더가 일어나요.
이름은 자유예요. 관례상 [값, set값] 꼴로 지어요 — [name, setName], [isOpen, setIsOpen]처럼요.
useState 같은 훅은 컴포넌트 함수의 맨 위에서 호출해요. 조건문·반복문 안에서 호출하면 안 돼요. (자세한 규칙은 훅 장에서 다룹니다.)state는 절대 직접 바꾸면 안 돼요. count++나 count = 5처럼 값을 직접 건드리면, 리액트가 변화를 알아채지 못해 화면이 다시 그려지지 않아요. 반드시 변경 함수(setter)를 호출해야 해요.
const [count, setCount] = useState(0);
// ❌ 직접 변경 — 무시됨. 리렌더 안 일어남
count++;
count = count + 1;
// ✅ setter 사용 — 리액트가 값을 바꾸고 화면을 다시 그림
setCount(1);
setCount(count + 1);
count++는 그냥 변수값을 조용히 바꾼 것뿐이라 리액트가 알 방법이 없어요. 게다가 다음 리렌더 때 count는 다시 원래 state 값으로 돌아옵니다.
규칙: state를 바꾼다 = setter를 호출한다. 예외는 없어요.
arr.push(x)나 obj.name = '…'처럼 직접 고치지 말고, 새 배열/객체를 만들어 setter에 넘겨요. (불변성 — 뒤 장에서 자세히 다룹니다.)비동기(asynchronous)란 "요청한 일이 그 즉시가 아니라 조금 이따 처리된다"는 뜻이에요. state가 바로 이래요. setter를 호출해도 그 자리에서 즉시 값이 바뀌는 게 아니에요. 리액트는 한 이벤트에서 일어난 여러 state 변경을 모아서(배치, batching) 한 번에 처리한 뒤 리렌더링해요. 그래서 setter를 부른 직후에 읽은 값은 아직 옛날 값일 수 있어요.
const [count, setCount] = useState(0);
function handleClick() {
// count 는 지금 0 이라고 가정
setCount(count + 1); // 0 + 1
setCount(count + 1); // 여전히 0 + 1 (count는 아직 0)
setCount(count + 1); // 여전히 0 + 1
// 결과: 3이 아니라 1! (세 번 다 "0 기준"으로 계산했기 때문)
}
setCount(c => c + 1) — "지금 값이 뭐든, 거기에 1 더해줘"라는 뜻. 이렇게 하면 위 코드도 정확히 3이 돼요.
function handleClick() {
setCount(c => c + 1); // 0 → 1
setCount(c => c + 1); // 1 → 2
setCount(c => c + 1); // 2 → 3
// 결과: 정확히 3 ✅ (앞 setter의 결과를 c로 받기 때문)
}
setCount(c => c + 1)를 써요. 그냥 새 값으로 갈아끼우는 거면 setCount(5)처럼 값을 바로 넘겨도 돼요.둘 다 "화면을 그리는 데이터"지만 출처와 권한이 달라요. props는 부모가 내려준 읽기 전용 값이고, state는 컴포넌트가 내부에서 직접 관리·변경하는 값이에요.
| 구분 | props | state |
|---|---|---|
| 누가 주나 | 부모 컴포넌트가 내려줌 | 컴포넌트가 스스로 가짐 |
| 바꿀 수 있나 | ❌ 읽기 전용 (못 바꿈) | ✅ setter로 바꿈 |
| 바꾸는 방법 | 부모가 다른 값을 내려줘야 함 | setState(setter) 호출 |
| 만드는 법 | 함수 인자(속성)로 받음 | useState()로 선언 |
| 예시 | 버튼에 넘긴 label, color | 카운터 숫자, 입력값, 열림/닫힘 |
<Toggle label="알림" />로 label(props)을 주면, Toggle 안에서 켜짐/꺼짐은 isOn이라는 state로 관리해요.
외우기: props = 위에서 받은 값(못 바꿈), state = 내가 가진 값(바꿀 수 있음).
const [값, set값] = useState(초기값) — 배열 구조분해로 [현재 값, setter]를 꺼내요.count++는 무시돼요.setCount(c => c + 1).