React 19.2 useState: 무엇을 저장하고 어떻게 안전하게 바꿀까
이 글은 함수 컴포넌트에서 렌더 사이에 기억되어야 하고 사용자 화면에 영향을 주는 지역 값을 useState로 관리하려는 독자를 위한 글입니다. 일반 변수가 화면을 갱신하지 않는 문제를 재현한 뒤, state의 스냅샷 동작, 직접 값과 updater 함수의 차이, 객체·배열·중첩 객체를 변이 없이 바꾸는 방법까지 연결합니다. 결과적으로 setter 호출 후 오래된 값이 보이는 이유와 업데이트가 누락되는 원인을 스스로 구분할 수 있게 됩니다.
검증 기준: React 공식 문서가 표시하는 React 19.2 API를 2026-07-19에 확인했습니다. Canary·Experimental API가 아니라 안정 버전의 useState를 다룹니다.
- 대상: 어떤 값을 state로 둘까
- 재현: 일반 변수로 화면이 바뀌지 않는 이유
- 원인: state는 렌더의 스냅샷이다
- 수정: pending state에는 updater를 쓴다
- 객체·배열·중첩 객체 업데이트
- 예외와 자주 생기는 오해
- 검증 체크리스트
- 공식 출처와 학습 경로
- 결론
대상: 어떤 값을 state로 둘까
useState는 컴포넌트에 state 변수를 추가하는 Hook입니다. state는 렌더 사이에 값을 보존하고 setter가 다음 렌더를 요청하게 만듭니다. 입력값, 선택된 탭, 펼침 여부, 사용자가 추가·삭제하는 목록처럼 상호작용 뒤 화면이 달라져야 하는 지역 데이터가 대표 대상입니다.
화면에 보인다고 모두 state는 아닙니다
props나 다른 state에서 바로 계산할 수 있는 값은 별도 state로 복제하지 않는 편이 좋습니다. 예를 들어 firstName과 lastName이 state라면 fullName은 렌더 중 문자열로 계산할 수 있습니다. 같은 정보를 두 곳에 저장하면 한쪽만 갱신되어 불일치가 생깁니다. React의 state 구조 선택 원칙도 중복·모순 state를 피하도록 안내합니다.
| 값 | 권장 위치 | 판단 기준 |
|---|---|---|
| 버튼 클릭 횟수 | useState |
렌더 사이에 기억되고 화면을 바꿉니다. |
| 부모가 전달한 제목 | props | 이미 상위 컴포넌트가 소유합니다. |
| 상품 목록의 총합 | 렌더 중 계산 | 현재 목록에서 계산 가능하면 중복 저장하지 않습니다. |
| 렌더와 무관한 DOM 참조 | useRef 검토 |
값 변경 자체가 화면 갱신을 요구하지 않습니다. |

재현: 일반 변수로 화면이 바뀌지 않는 이유
다음 컴포넌트는 클릭할 때 지역 변수 count를 증가시킵니다. 하지만 변수 변경만으로 React에 새 렌더를 요청하지 못합니다. 컴포넌트가 다른 이유로 다시 렌더되면 함수가 다시 실행되어 지역 변수도 다시 0에서 시작합니다.
export default function WrongCounter() {
let count = 0;
function handleClick() {
count += 1;
console.log(count);
}
return (
<button type="button" onClick={handleClick}>
현재 값: {count}
</button>
);
}
수정: 값과 setter를 한 쌍으로 선언합니다
useState(initialState)는 현재 state와 setter 두 값을 돌려줍니다. Hook은 컴포넌트 또는 커스텀 Hook의 최상위에서 호출해야 하며 조건문·반복문 안에서 호출하지 않습니다.
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<button type="button" onClick={handleClick}>
현재 값: {count}
</button>
);
}
초기값 인수는 첫 렌더에서만 state를 정하는 데 사용됩니다. 초기 계산이 비싸다면 useState(createInitialItems)처럼 순수한 initializer 함수 자체를 전달해 이후 렌더마다 계산식을 호출하지 않게 할 수 있습니다. 정확한 시그니처와 제약은 React useState API 참조에서 확인할 수 있습니다.
원인: state는 현재 렌더의 스냅샷입니다
setter를 호출해도 이미 실행 중인 이벤트 핸들러의 count 변수는 바뀌지 않습니다. 현재 렌더가 받은 값은 그 렌더 안에서 고정된 스냅샷이고, setter는 다음 렌더에서 사용할 값을 큐에 넣습니다. 그래서 다음 코드는 화면은 이후 1 증가하지만 로그에는 클릭 전 값이 찍힙니다.
function handleClick() {
setCount(count + 1);
console.log(count); // 현재 렌더의 이전 값
}
“setter가 비동기라서”만으로 설명하지 않습니다
핵심은 단순한 시간 지연이 아니라 렌더마다 state 스냅샷이 정해진다는 점입니다. setter 뒤에 같은 함수를 다시 읽어도 현재 핸들러가 가진 값은 그대로입니다. 새 값이 필요한 로직은 다음 렌더에서 사용하거나, 다음 값을 지역 변수로 먼저 계산하거나, 이전 state에 의존한다면 updater 함수로 표현합니다. 자세한 모델은 State as a Snapshot에 설명되어 있습니다.
수정: pending state에 의존하면 updater를 씁니다
같은 이벤트에서 setCount(count + 1)을 세 번 호출하면 세 호출 모두 같은 스냅샷의 count를 읽습니다. 결과는 3 증가가 아니라 같은 다음 값으로 교체되는 1 증가입니다. updater는 큐에서 앞 업데이트의 결과를 받아 다음 값을 계산합니다.
function addThreeWrong() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
function addThreeRight() {
setCount((pending) => pending + 1);
setCount((pending) => pending + 1);
setCount((pending) => pending + 1);
}
updater가 항상 의무인 것은 아닙니다
setSelectedId(clickedId)처럼 다음 값이 이전 state와 무관하면 직접 값을 전달해도 됩니다. 반대로 토글, 증가·감소, 이전 배열에 항목 추가처럼 다음 값이 같은 state의 pending 값에 의존하면 updater가 의도를 더 정확히 표현합니다. 여러 업데이트의 큐 처리와 batching은 Queueing a Series of State Updates를 기준으로 합니다.
객체·배열·중첩 객체 업데이트
React state의 객체와 배열은 읽기 전용처럼 다룹니다. 기존 객체를 수정한 뒤 같은 참조를 setter에 넘기는 대신, 바뀐 부분을 포함한 새 객체나 새 배열을 만들어 교체합니다.
객체: 기존 필드를 복사하고 한 필드만 교체
import { useState } from 'react';
export default function ProfileForm() {
const [profile, setProfile] = useState({
name: '',
role: 'member',
});
function handleNameChange(event) {
setProfile((current) => ({
...current,
name: event.target.value,
}));
}
return (
<label>
이름
<input value={profile.name} onChange={handleNameChange} />
</label>
);
}
배열: 추가·수정·삭제마다 새 배열 생성
function addTodo(newTodo) {
setTodos((current) => [...current, newTodo]);
}
function toggleTodo(id) {
setTodos((current) =>
current.map((todo) =>
todo.id === id
? { ...todo, done: !todo.done }
: todo,
),
);
}
function removeTodo(id) {
setTodos((current) =>
current.filter((todo) => todo.id !== id),
);
}
중첩 객체: 바뀌는 경로의 각 단계를 복사
setProfile((current) => ({
...current,
address: {
...current.address,
city: 'Seoul',
},
}));
전개 문법은 얕은 복사입니다. 최상위 객체만 펼치고 내부 address.city를 직접 수정하면 내부 객체 참조는 그대로 공유됩니다. 객체와 배열별 패턴은 Updating Objects in State와 Updating Arrays in State에서 더 확인할 수 있습니다.

예외와 자주 생기는 오해
같은 값 또는 같은 참조면 렌더가 생략될 수 있습니다
React는 새 state와 현재 state를 Object.is로 비교합니다. 객체를 직접 변이한 뒤 같은 객체를 전달하면 React 관점에서는 같은 참조일 수 있어 화면 갱신이 생략됩니다. 변이를 피해야 하는 이유는 단순한 스타일이 아니라 이 비교와 이전 렌더의 스냅샷을 보존하기 위해서입니다.
Strict Mode의 두 번 호출은 개발 전용 순수성 검사입니다
Strict Mode에서는 initializer와 updater가 우발적으로 순수하지 않은지 찾기 위해 개발 중 두 번 호출될 수 있고 한 결과는 버립니다. production에서 두 번 상태를 적용한다는 뜻이 아닙니다. initializer와 updater 안에서는 네트워크 요청, 외부 변수 변경, state 객체 변이 같은 부수 효과를 실행하지 않습니다.
복잡한 연관 state는 구조를 다시 봅니다
여러 state가 항상 함께 바뀌거나 다음 값이 다른 state에 의존한다면 하나의 객체로 묶거나 useReducer를 검토할 수 있습니다. 다만 단순 카운터나 입력값까지 무조건 reducer나 전역 store로 옮길 필요는 없습니다. 상태의 소유 범위와 변경 규칙의 복잡도가 선택 기준입니다.
검증 체크리스트
재현과 수정 결과를 같은 동작으로 비교합니다
| 검증 동작 | 기대 결과 | 실패하면 볼 곳 |
|---|---|---|
| 일반 변수 카운터 클릭 | 콘솔과 달리 화면 갱신이 보장되지 않음 | 렌더를 요청하는 state가 있는지 |
setCount 뒤 즉시 로그 |
현재 렌더의 이전 값 | 스냅샷을 즉시 변이로 오해했는지 |
| updater 세 번 호출 | 정확히 3 증가 | 직접 값 세 번으로 작성했는지 |
| 프로필 이름 변경 | role을 보존하며 이름만 변경 |
이전 객체 복사 여부 |
| 목록 토글·삭제 | 대상 항목만 변경되고 원본 배열은 변이되지 않음 | map, filter, 새 객체 생성 여부 |
오류를 좁히는 순서
- 이 값이 state여야 하는지, props·계산값·ref인지 먼저 구분합니다.
- 이벤트 핸들러가 실제로 실행되고 setter까지 도달하는지 확인합니다.
- 다음 값이 같은 state의 pending 값에 의존하는지 확인합니다.
- 객체·배열의 기존 참조를 직접 수정하지 않았는지 확인합니다.
- Strict Mode 개발 로그를 production의 중복 적용으로 오해하지 않았는지 확인합니다.
React 공식 출처와 내부 학습 경로
확인일: 2026-07-19. 기술 동작은 React 공식 문서만 근거로 사용했습니다.
공식 문서
- React Versions
- useState API Reference
- State as a Snapshot
- Queueing a Series of State Updates
- Updating Objects in State
- Updating Arrays in State
- Choosing the State Structure
내부 학습 경로
- 선수: React 컴포넌트 개념 정리
- 다음: React props 사용법
- 문제 해결: React state 업데이트 안됨 문제 해결
결론: 최소한의 state와 새 다음 값을 설계하세요
useState를 안전하게 쓰는 기준은 세 가지입니다. 렌더 사이에 기억되어야 하고 화면에 영향을 주는 최소 정보만 state로 둡니다. 현재 핸들러의 state는 스냅샷이므로 같은 state의 pending 값에서 다음 값을 계산할 때 updater를 사용합니다. 객체·배열은 직접 고치지 않고 바뀐 경로마다 새 값을 만들어 교체합니다.
다음 코드를 작성할 때는 먼저 “이 값은 계산할 수 있는가?”, “다음 값이 이전 값에 의존하는가?”, “새 참조를 만들었는가?”를 확인하세요. 이 세 질문을 통과한 뒤 위 검증 표의 +3, 객체 필드 보존, 배열 토글을 직접 실행하면 스냅샷 문제와 변이 문제를 분리해 확인할 수 있습니다.
“React useState 사용법: state와 객체 배열 업데이트 기준”에 대한 3개의 생각