2025-10-07 13:40
-
useState는 함수형 컴포넌트에서 상태를 관리하기 위해 탄생한 가장 기본적인 React Hook이다.
-
const [state, setState] = useState(initialValue)구문을 통해 상태와 그 상태를 변경하는 함수를 쌍으로 제공한다. -
상태를 업데이트할 때는 불변성을 유지하는 것이 핵심이며, React는 이를 통해 효율적으로 UI 변경을 감지하고 렌더링한다.
React useState 완벽 정복 핸드북 모든 것을 알려드립니다
React의 세계에 발을 들인 개발자라면 누구나 가장 먼저 만나는 관문, 바로 useState이다. 이것은 단순히 값을 저장하는 변수 이상의 의미를 가진다. useState는 정적이었던 웹 페이지에 생명을 불어넣어 사용자와 상호작용하는 동적인 애플리케이션으로 만들어주는 마법의 첫걸음이다. 하지만 이 마법을 제대로 부리기 위해서는 그 원리와 구조, 그리고 올바른 사용법을 깊이 이해해야 한다.
이 핸드북은 useState의 탄생 배경부터 기본 사용법, 그리고 현업 개발자들이 마주치는 심화 주제와 흔한 실수까지, useState에 대한 모든 것을 담았다. 이 글을 끝까지 읽는다면, 당신은 더 이상 useState를 두려워하지 않고 자신감 있게 상태를 관리하는 개발자로 거듭날 것이다.
1. 탄생의 서사 왜 useState가 필요했을까
useState를 이해하려면 먼저 그것이 해결하고자 했던 문제가 무엇인지 알아야 한다. useState가 등장하기 전, React의 컴포넌트는 주로 클래스(Class)를 기반으로 작성되었다.
클래스 컴포넌트의 시대와 그 한계
과거의 클래스 컴포넌트는 상태를 관리하기 위해 constructor에서 this.state를 초기화하고, 상태를 변경할 때는 this.setState() 메서드를 사용했다.
JavaScript
import React, { Component } from 'react';
class Counter extends Component {
constructor(props) {
super(props);
// 생성자에서 state 초기화
this.state = {
count: 0,
};
}
handleIncrement = () => {
// this.setState를 통해 상태 업데이트
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={this.handleIncrement}>
Click me
</button>
</div>
);
}
}
이 방식은 잘 동작했지만 몇 가지 고질적인 문제를 안고 있었다.
-
장황한 문법과
this의 함정: 간단한 상태 하나를 관리하기 위해constructor,super(props)등 많은 상용구(boilerplate) 코드가 필요했다. 또한, JavaScript의this키워드는 컨텍스트에 따라 가리키는 대상이 달라져 개발자들을 혼란에 빠뜨리는 주범이었다. 이벤트 핸들러에서this를 컴포넌트 인스턴스에 바인딩하는 작업은 번거롭고 실수하기 쉬웠다. -
로직 재사용의 어려움: 서로 다른 컴포넌트에서 비슷한 상태 관리 로직(예: 데이터 fetching)을 재사용하기가 까다로웠다. 이를 해결하기 위해 HOC(Higher-Order Components)나 Render Props 같은 패턴이 등장했지만, 이는 오히려 컴포넌트 구조를 복잡하게 만드는 “Wrapper Hell” 현상을 낳기도 했다.
-
기계와 인간 모두에게 어려운 최적화: 클래스 컴포넌트는 생명주기 메서드(
componentDidMount,componentDidUpdate등)가 복잡하게 얽히기 쉬워 코드를 이해하고 최적화하기 어려웠다.
구원투수의 등장 React Hooks
이러한 문제들을 해결하기 위해 React 팀은 React 16.8 버전에서 Hooks라는 혁신적인 개념을 도입했다. Hooks의 핵심 철학은 **“클래스를 작성하지 않고도 state와 다른 React의 기능들을 사용할 수 있게 하자”**는 것이었다.
Hooks는 함수형 컴포넌트에 날개를 달아주었다. 기존의 함수형 컴포넌트는 상태를 가질 수 없어 단순히 props를 받아 UI를 렌더링하는 역할에 그쳤지만, Hooks의 등장으로 자신만의 상태를 갖고 생명주기 기능을 수행하는 것이 가능해졌다.
그리고 그 Hooks의 가장 기본적이자 중심이 되는 것이 바로 useState이다. useState는 함수형 컴포넌트 내부에 ‘상태’라는 기억 공간을 만들어, 함수가 다시 호출되더라도 그 값을 유지할 수 있게 해준다.
2. useState 해부학 구조와 작동 원리
useState의 기본 문법은 매우 간결하다. 이 간결함 속에 강력한 기능이 숨어있다.
JavaScript
const [state, setState] = useState(initialState);
이 한 줄의 코드를 해부해 보자.
구성 요소 톺아보기
-
useState(initialState):useState는 React가 제공하는 함수(Hook)이다. 이 함수를 호출하면 컴포넌트에 상태를 ‘추가’할 수 있다. 인자로는initialState(초기 상태값)를 받는다. 이 초기값은 컴포넌트가 최초로 렌더링될 때 단 한 번만 사용된다. -
[state, setState]:useState는 항상 길이가 2인 배열을 반환한다.-
state(상태 변수): 현재 상태 값을 담고 있는 변수. 이 변수는 직접 수정할 수 없다 (읽기 전용).const로 선언하는 이유도 이 때문이다. -
setState(상태 설정 함수): 상태(state)를 변경할 수 있는 유일한 함수. 이 함수를 호출하면 React에게 “상태가 변경되었으니 컴포넌트를 다시 렌더링 해주세요”라고 요청하는 것과 같다. 이 함수의 이름은 관례적으로set+상태변수명(예:setCount,setName)으로 짓는다.
-
이 배열을 반환하는 문법은 JavaScript의 **배열 비구조화 할당(Array Destructuring)**이다. 덕분에 우리는 state와 setState에 원하는 이름을 자유롭게 붙여줄 수 있다.
비유로 이해하기 개인 사물함
useState를 개인 사물함에 비유할 수 있다.
-
useState(): “사물함 하나 주세요”라고 요청하는 행위. -
initialState: 사물함에 처음 넣어두는 물건. -
state: 사물함 안에 지금 들어있는 물건을 확인하는 것. (내용물을 직접 바꿀 순 없다.) -
setState: 사물함 관리인에게 “이 새 물건으로 바꿔주세요”라고 요청하는 행위. 관리인은 요청을 받아 내용물을 바꾸고, 변경되었다는 사실(리렌더링)을 모두에게 알려준다.
비동기적 작동의 비밀
setState를 호출한다고 해서 state 값이 즉시 바뀌는 것은 아니다. 이는 useState를 사용할 때 가장 많이 겪는 혼란 중 하나다.
JavaScript
function Counter() {
const [count, setCount] = useState(0);
const handleTripleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log(count); // 0이 출력된다!
};
// ...
}
위 코드에서 버튼을 클릭하면 count는 3이 아니라 1만 증가한다. console.log(count) 역시 1이 아닌 0을 출력한다. 왜일까?
React는 성능 최적화를 위해 여러 개의 setState 호출을 **일괄 처리(Batching)**하기 때문이다. handleTripleClick 함수 내에서 setCount가 세 번 호출되었지만, React는 이를 모아서 “count를 1로 변경하라”는 단 한 번의 작업으로 처리한다. 왜냐하면 세 번의 호출 모두 동일한 count 값(0)을 기준으로 0 + 1을 계산했기 때문이다.
이처럼 setState는 비동기적으로 작동하며, 다음 렌더링 사이클에서 상태 변경이 반영된다. 이러한 특징을 이해하는 것은 useState를 올바르게 사용하는 데 매우 중요하다.
3. useState 실전 사용 가이드
이제 이론을 바탕으로 useState를 실제 코드에서 어떻게 활용하는지 알아보자.
기본 데이터 타입 다루기
숫자(Number), 문자열(String), 불리언(Boolean) 같은 원시 타입은 직관적으로 사용할 수 있다.
JavaScript
import React, { useState } from 'react';
function UserProfile() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [username, setUsername] = useState('Guest');
const handleLogin = () => {
setIsLoggedIn(true);
setUsername('Alice');
};
const handleLogout = () => {
setIsLoggedIn(false);
setUsername('Guest');
};
return (
<div>
<h1>Welcome, {username}!</h1>
{isLoggedIn ? (
<button onClick={handleLogout}>Logout</button>
) : (
<button onClick={handleLogin}>Login</button>
)}
</div>
);
}
참조 타입 다루기 객체와 배열 (⭐️⭐️⭐️⭐️⭐️)
객체(Object)나 배열(Array) 같은 참조 타입을 useState로 다룰 때는 **불변성(Immutability)**이라는 원칙을 반드시 지켜야 한다. 즉, 원본 객체나 배열을 직접 수정해서는 안 된다.
왜 불변성이 중요한가?
React는 상태가 변경되었는지 판단하기 위해 이전 상태와 새로운 상태를 비교한다. 이때 참조 타입의 경우, 내부 속성이 바뀌었는지가 아니라 메모리 주소(참조)가 바뀌었는지를 얕게 비교(Shallow Comparison)한다. 만약 원본 객체를 직접 수정하면 메모리 주소는 그대로이기 때문에 React는 변화를 감지하지 못하고 리렌더링을 생략해버린다.
객체 상태 업데이트
잘못된 방법 (직접 수정)
JavaScript
const [user, setUser] = useState({ name: 'Alice', age: 25 });
const handleAgeIncrement = () => {
// 🚨 절대 이렇게 하면 안 된다!
user.age = user.age + 1;
setUser(user); // 주소값이 같아서 React가 변화를 감지 못함
};
올바른 방법 (새로운 객체 생성)
전개 구문(Spread Syntax ...)을 사용하여 기존 객체의 속성을 복사한 뒤, 변경하려는 속성만 덮어쓰는 방식으로 새로운 객체를 만들어야 한다.
JavaScript
const [user, setUser] = useState({ name: 'Alice', age: 25 });
const handleAgeIncrement = () => {
setUser({
...user, // 기존 user 객체의 모든 속성을 복사하고
age: user.age + 1, // age 속성만 새로운 값으로 덮어쓴다.
});
};
배열 상태 업데이트
배열도 마찬가지다. push, pop, splice처럼 원본 배열을 직접 변경하는 메서드는 사용하면 안 된다. 대신 map, filter, concat이나 전개 구문처럼 새로운 배열을 반환하는 메서드를 사용해야 한다.
JavaScript
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', done: false }
]);
// 항목 추가하기
const addTodo = (text) => {
const newTodo = { id: Date.now(), text, done: false };
setTodos([...todos, newTodo]); // 기존 배열 뒤에 새 항목을 추가한 새 배열 생성
};
// 항목 삭제하기 (filter)
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id)); // 특정 id를 제외한 새 배열 생성
};
// 항목 토글하기 (map)
const toggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
) // 특정 id의 항목만 변경한 새 배열 생성
);
};
함수형 업데이트 이전 상태를 안전하게 사용하기
앞서 setState가 비동기적으로 작동한다고 설명했다. 만약 이전 상태 값에 의존하여 다음 상태를 결정해야 한다면 어떻게 해야 할까? 예를 들어, 1초 안에 버튼을 여러 번 클릭했을 때 클릭한 횟수만큼 숫자가 정확히 올라가게 하려면 말이다.
이때 **함수형 업데이트(Functional Updates)**를 사용한다. setState에 새로운 값 대신, 이전 상태(previous state)를 인자로 받아 새로운 상태를 반환하는 함수를 넣어주는 방식이다.
JavaScript
const [count, setCount] = useState(0);
const handleRapidClick = () => {
// setCount(count + 1); // 🚩 비동기 문제로 여러 번 호출해도 1만 증가할 수 있음
// ✅ 함수형 업데이트 사용
setCount(prevCount => prevCount + 1);
};
이렇게 함수를 인자로 넘기면, React는 이 함수를 업데이트 큐에 넣어 순차적으로 실행한다. 이때 React는 각 함수의 인자로 가장 최신의 상태 값을 넘겨줄 것을 보장한다. 따라서 여러 번의 업데이트 요청이 있더라도 상태 값이 유실되거나 덮어쓰일 걱정 없이 안전하게 상태를 갱신할 수 있다.
4. useState 심화 탐구
기본적인 사용법을 익혔다면, 이제 useState를 더 효율적이고 정교하게 사용하는 방법을 알아보자.
지연 초기화 (Lazy Initial State)
useState의 초기값(initialState)을 설정하는 과정이 복잡하고 무거운 연산(예: localStorage에서 데이터 읽어오기)일 경우가 있다.
JavaScript
// 컴포넌트가 리렌더링될 때마다 getInitialValue()가 계속 호출됨
const [value, setValue] = useState(getInitialValue());
위와 같이 작성하면, getInitialValue 함수는 컴포넌트가 리렌더링될 때마다 불필요하게 계속 실행된다. (물론 그 결과값은 최초 렌더링 시에만 사용되고 이후에는 무시된다.)
이를 방지하기 위해 useState에 함수를 직접 전달할 수 있다. 이것을 지연 초기화라고 한다.
JavaScript
// 최초 렌더링 시에만 getInitialValue 함수가 실행됨
const [value, setValue] = useState(() => {
const initialValue = getInitialValue();
return initialValue;
});
이렇게 하면 useState는 인자로 받은 함수를 최초 렌더링 시에만 단 한 번 실행하여 그 반환값으로 상태를 초기화한다. 불필요한 연산을 막아 성능을 최적화할 수 있는 유용한 기법이다.
언제 useState 대신 useReducer를 써야 할까?
상태 관리 로직이 복잡해지면 useState만으로는 한계에 부딪힐 수 있다. 여러 개의 상태가 서로 유기적으로 관련되어 있거나, 상태 변경 로직이 다양할 때가 그렇다. 이럴 때 useState의 대안으로 useReducer를 고려할 수 있다.
| 구분 | useState | useReducer |
|---|---|---|
| 적합한 상황 | • 독립적인 단순 상태 (숫자, 문자열, 불리언) • 상태 변경 로직이 간단할 때 | • 여러 하위 값이 있는 복잡한 상태 객체 • 다음 상태가 이전 상태에 복잡하게 의존할 때 • 상태 변경 로직(액션)이 다양하고 명확히 구분될 때 |
| 업데이트 방식 | 상태 설정 함수를 직접 호출 (setCount(1)) | dispatch 함수를 통해 “액션”을 전달 (dispatch({ type: 'INCREMENT' })) |
| 코드 구조 | 컴포넌트 내에 상태 변경 로직이 흩어짐 | 상태 변경 로직을 컴포넌트 외부의 reducer 함수로 분리하여 관리 용이 |
| 비유 | 전등 스위치: 켜고 끄는 단순한 동작 | 자판기: 동전을 넣고 버튼을 누르면(액션) 정해진 로직에 따라 음료수(새로운 상태)가 나옴 |
결론: 상태 관리 로직이 “무엇을 할지(what)“뿐만 아니라 “어떻게 할지(how)“까지 명확하게 정의해야 할 만큼 복잡해진다면, useReducer로 전환하는 것이 코드의 가독성과 유지보수성을 높이는 좋은 선택이다.
5. 흔한 실수와 모범 사례
useState를 사용하며 저지르기 쉬운 실수와 이를 방지하기 위한 모범 사례를 알아보자.
Hooks의 규칙을 기억하라
React Hooks에는 반드시 지켜야 할 두 가지 규칙이 있다.
-
최상위 레벨에서만 Hooks를 호출해야 한다. 반복문, 조건문, 중첩 함수 내에서
useState를 호출하면 안 된다. React는 Hooks가 호출되는 순서에 의존하여 상태를 관리하기 때문에, 호출 순서가 매번 동일하게 유지되어야 한다. -
오직 React 함수 컴포넌트 내에서만 Hooks를 호출해야 한다. 일반 JavaScript 함수에서는 Hooks를 호출할 수 없다.
이 규칙들은 Linter 플러그인(eslint-plugin-react-hooks)을 사용하면 쉽게 지킬 수 있다.
흔한 실수 TOP 3
-
상태를 직접 수정하는 실수:
user.age = 30;처럼 객체나 배열을 직접 수정하는 것은 가장 흔하고 치명적인 실수다. 항상 새로운 객체/배열을 생성하여setState에 전달해야 한다. -
setState가 동기적이라고 착각하는 실수:setState호출 직후에 변경된 상태 값에 접근하려 하면 이전 값을 보게 된다. 변경된 상태 값을 기반으로 무언가를 해야 한다면useEffectHook을 사용해야 한다. -
불필요하게 많은
useState선언: 서로 밀접하게 관련된 상태들을 여러 개의useState로 분리하면 코드가 지저분해지고 관리하기 어려워진다. 예를 들어,const [firstName, setFirstName] = useState(''),const [lastName, setLastName] = useState('')보다는const [name, setName] = useState({ first: '', last: '' })처럼 하나의 객체로 묶는 것이 더 나을 수 있다. (물론, 너무 관련 없는 상태를 한 객체에 묶는 것도 좋지 않다.)
결론: 상태 관리의 첫 단추를 꿰다
useState는 React의 함수형 컴포넌트 패러다임을 여는 열쇠와 같다. 그것은 단순한 변수 선언을 넘어, 컴포넌트의 생명과 상호작용의 중심에 있다. 그 간결함 뒤에 숨겨진 비동기적 특성과 불변성의 원칙을 이해하는 것은 React를 깊이 있게 다루기 위한 필수 과정이다.
이 핸드북을 통해 useState의 탄생 배경부터 작동 원리, 실전 사용법, 그리고 심화 주제까지 살펴보았다. 이제 당신은 useState를 사용하여 컴포넌트의 상태를 자신감 있게 설계하고 관리할 준비가 되었다. 기억하라, 모든 위대한 React 애플리케이션은 가장 작은 useState 호출에서 시작된다. 이제 직접 코드를 작성하며 useState의 마법을 경험해볼 시간이다.