useState는 함수형 컴포넌트가 클래스 컴포넌트로 변환하지 않고 로컬 상태를 관리할 수 있게 해주는 React 내장 훅입니다. React 16.8이 훅을 도입했고, useState는 그 중에서 가장 널리 사용되며 클래스 기반 접근 방식의 this.state와 this.setState를 대체했습니다. React 문서(2025)에 따르면, 최신 React 애플리케이션의 80% 이상의 컴포넌트가 폼 데이터, 인터페이스 플래그 및 카운터 관리에 useState를 사용합니다. 이 훅은 현재 값과 상태를 업데이트하고 컴포넌트의 재렌더링을 트리거하는 세터 함수로 구성된 튜플을 반환합니다.
핵심 요점
useState는 버전 16.8에서 추가된 React의 기본 훅으로, 함수형 컴포넌트에 로컬 상태를 저장하고 변경할 수 있는 기능을 제공합니다. 훅이 도입되기 전에는 클래스 컴포넌트에서만 this.state와 this.setState를 통해 상태를 사용할 수 있었으며, 함수형 컴포넌트는 순수 프레젠테이션용이었습니다. useState는 이 제한을 제거하여 함수형 컴포넌트를 사용해 전체 애플리케이션을 작성할 수 있게 했습니다.
훅은 하나의 인수(초기 상태 값)를 받아 두 요소의 배열을 반환합니다. 첫 번째 요소는 현재 상태 값이고, 두 번째는 이를 업데이트하는 함수입니다. React는 세터 함수가 안정적이고 렌더링 간에 변경되지 않음을 보장하므로, 자식 컴포넌트에 안전하게 전달하고 클로저에서 사용할 수 있습니다.
React DevTools 사용량 조사(2024)에 따르면, useState는 React 애플리케이션의 96%에서 사용되어 생태계에서 가장 일반적인 훅입니다. Redux나 Zustand를 통해 전역 상태를 사용하는 애플리케이션에서도 useState를 통한 로컬 상태는 UI 상태(열린 모달, 입력 필드 값, 활성 탭)를 관리하는 기본 메커니즘으로 남아 있습니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
useState의 내부 메커니즘은 React의 파이버 노드 시스템을 기반으로 합니다. React의 각 컴포넌트는 연결된 훅의 링크드 리스트를 저장하는 파이버 노드로 표현됩니다. 컴포넌트가 useState를 호출하면 React는 이 리스트에 새 노드를 만들고 그 안에 현재 상태 값과 업데이트 큐에 대한 참조를 저장합니다.
세터 함수가 호출되면 React는 즉시 상태를 업데이트하지 않습니다. 대신 업데이트를 보류 중인 상태 큐에 넣고 컴포넌트 재렌더링을 예약한 후, 다음 렌더링 중에만 이전 상태와 적용된 업데이트를 기반으로 새 상태를 계산합니다. 이는 배칭을 보장합니다. 즉, 하나의 이벤트 핸들러에서 세터를 세 번 호출하면 React가 이를 단일 재렌더링으로 그룹화합니다.
React 팀 — React 18 워킹 그룹(2024)에 따르면, React 18에서 자동 배칭이 확장되어 상태 업데이트가 이벤트 핸들러뿐만 아니라 setTimeout, Promise 콜백 및 네이티브 이벤트 핸들러에서도 그룹화됩니다. 이는 여러 상태 업데이트가 있는 시나리오에서 최대 30%의 성능 향상을 제공했습니다.
새 상태가 이전 상태에 의존하는 경우 세터의 함수형 형태를 사용하세요: setCount(prev => prev + 1). React는 업데이트 시점의 실제 상태 값을 함수에 전달하여 배치된 업데이트에서도 정확성을 보장합니다. 함수형 형태 없이 setCount(count + 1)을 연속으로 여러 번 호출하면 오래된 값을 사용할 수 있습니다.
// 지연 초기화 — 한 번만 실행됨
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// Result: count increased by 3
// NOT batch-safe: stale closure
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Result: count increased by 1 (stale closure)
useState의 기본 구문은 매우 간결합니다: const [state, setState] = useState(initialValue). 초기 값은 첫 번째 렌더링 중에만 사용되며, 이후 렌더링에서는 React가 이를 무시하고 현재 저장된 값을 반환합니다. 초기 계산에 비용이 많이 드는 작업이 필요한 경우 초기화 함수를 전달하세요: useState(() => computeExpensiveInitial()).
지연 초기화는 초기 값이 localStorage, 복잡한 데이터 변환 또는 URL 매개변수 분석에서 오는 경우 특히 중요합니다. React는 초기화 함수를 한 번만 호출합니다(컴포넌트 마운트 시). 이는 이후 렌더링에서 리소스를 절약합니다. 지연 초기화가 없으면 비용이 많이 드는 표현식은 결과가 무시되더라도 매 렌더링마다 평가됩니다.
| 형태 | 예시 | 사용 시기 |
|---|---|---|
| 직접 | useState(0) | 간단한 초기 값 |
| 지연 | useState(() => compute()) | 비용이 많이 드는 초기 계산 |
| 함수형 세터 | setState(prev => prev + 1) | 이전 값 기반 업데이트 |
| 직접 세터 | setState(newValue) | 새 값이 이전 값에 의존하지 않음 |
// 객체 상태 — 새 참조 만들기
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('user');
return saved ? JSON.parse(saved) : null;
});
useState는 원시 유형(숫자, 문자열, 부울)과 참조 유형(배열, 객체) 모두에서 잘 작동합니다. 그러나 중요한 차이점이 있습니다. 원시 유형의 경우 React는 값으로 변경을 감지하고, 객체와 배열의 경우 참조로 감지합니다. 새 객체를 만들지 않고 기존 객체를 변경하면 참조가 동일하게 유지되므로 재렌더링이 트리거되지 않습니다.
객체 및 배열 작업 시 항상 수정된 데이터로 새 복사본을 만드세요. 객체의 경우 스프레드 구문을 사용하세요: setUser(prev => ({...prev, name: newName})). 배열의 경우 새 배열을 반환하는 메서드(filter, map, concat) 또는 요소 추가를 위한 스프레드 구문을 사용하세요. push, pop, splice와 같은 변경 메서드는 작동하지 않습니다 — 배열 참조가 변경되지 않았으므로 React가 이를 무시합니다.
// ✅ 올바름: 훅이 최상위 레벨에 있음
const [form, setForm] = useState({ name: '', email: '' });
const updateField = (field, value) =>
setForm(prev => ({ ...prev, [field]: value }));
// ...
const [items, setItems] = useState([]);
const addItem = item =>
setItems(prev => [item, ...prev]);
모든 React 훅과 마찬가지로 useState는 두 가지 규칙을 따릅니다. 첫째, 훅은 컴포넌트의 최상위 레벨에서만 호출하고 조건문, 루프 또는 중첩 함수 내부에 배치하지 마세요. 이렇게 하면 훅이 매 렌더링마다 동일한 순서로 호출되어 React의 내부 링크드 리스트에 중요합니다.
둘째, 훅은 React 함수형 컴포넌트 또는 사용자 정의 훅에서만 호출하세요. 일반 함수, 콜백 또는 클래스 컴포넌트에서 useState를 호출하지 마세요. 이러한 규칙을 위반하면 React가 런타임에 잡는 Invalid hook call 오류가 발생합니다.
// ❌ 오래된 클로저 문제
function GoodComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// ✅ 함수형 업데이트로 수정됨
}
// count가 오래되었습니다!
function BadComponent() {
if (isLogged) {
const [user, setUser] = useState(null);
}
}
가장 흔한 실수는 새 참조를 만드는 대신 객체와 배열을 직접 변경하는 것입니다. 개발자는 클래스 컴포넌트의 가변 스타일에 익숙하며, this.state.user.name = “새 이름”이 작동했습니다(권장되지는 않았지만). 함수형 컴포넌트에서는 이러한 변경이 무시됩니다. React는 참조 변경을 감지하지 못하고 컴포넌트를 재렌더링하지 않습니다.
오래된 클로저 문제는 useEffect 또는 이벤트 핸들러에 전달된 콜백이 콜백 생성 시점의 클로저에서 오래된 상태 값을 캡처할 때 발생합니다. 해결책은 세터의 함수형 형태를 사용하거나 useEffect 의존성 배열에 필요한 값을 포함하는 것입니다. React 문서 — 훅 FAQ(2025)에 따르면, 오래된 클로저는 잘못된 의존성 배열 다음으로 훅 버그의 두 번째로 흔한 원인입니다.
// ❌ Stale closure problem
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // count is stale!
}, 1000);
return () => clearInterval(timer);
}, []);
// ✅ Fixed with functional update
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
자주 묻는 질문
네, 하지만 주의가 필요합니다. 깊게 중첩된 객체의 경우 각 레벨을 업데이트할 때 복사가 필요합니다: setState(prev => ({...prev, nested: {...prev.nested, key: newValue}})). 3개 이상의 중첩 레벨이 있는 객체가 있다면 Immer(불변 업데이트를 위한 라이브러리)를 고려하거나 여러 useState 호출로 상태를 분할하세요.
useState는 세터를 호출해도 값이 즉시 업데이트되지 않는다는 의미에서 비동기입니다. React는 업데이트를 큐에 넣고 다음 렌더링에서 적용합니다. setCount(newValue) 이후에도 현재 함수의 count 변수는 여전히 이전 값을 포함합니다. 새 값은 다음 컴포넌트 호출에서만 사용할 수 있습니다. 배칭 메커니즘은 여러 호출이 단일 렌더링으로 그룹화되도록 보장합니다.
React는 Invalid hook call 오류를 발생시킵니다. 훅은 React 함수형 컴포넌트 또는 사용자 정의 훅(이름이 use로 시작하는 함수) 내에서만 호출할 수 있습니다. 이 제한은 훅 구현에 내장되어 있으며, 훅은 상태 저장을 위해 컴포넌트의 파이버 트리를 사용하고 컴포넌트 외부에서는 이 트리를 사용할 수 없습니다.
useState는 간단한 독립 상태(카운터, 입력 필드, 플래그)에 적합합니다. useReducer는 상태가 여러 필드를 가진 복잡한 객체이거나 업데이트 로직이 작업 유형에 따라 달라지거나 다음 상태에 복잡한 변환이 필요할 때 더 좋습니다. 실용적인 규칙: 3개 이상의 상호 관련된 상태 필드가 있으면 useReducer를 사용하세요.
이는 성능상의 이유입니다. useState가 동기식으로 상태를 업데이트하면 각 세터 호출이 즉시 재렌더링을 트리거하여 단일 이벤트 핸들러 내에서 여러 번의 다시 그리기가 발생합니다. React가 업데이트를 그룹화하고 단일 재렌더링을 수행하는 배칭은 React 16에서 도입되고 React 18에서 자동 배칭으로 크게 개선된 최적화입니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.