🎯 학습 목표
- 객체·배열 state의 불변 업데이트 패턴을 익힌다
- 함수형 업데이트(
setState(prev => ...))가 필요한 상황을 이해한다 - React 18의 자동 배칭(Automatic Batching)이 무엇인지 이해한다
- 이벤트 객체(
e.preventDefault,e.stopPropagation)를 올바르게 사용한다 - 합성 이벤트(SyntheticEvent)가 무엇인지 이해한다
📖 핵심 개념 1 — 불변 업데이트 (Immutable Update)
React에서 state를 업데이트할 때는 기존 값을 직접 수정(mutation)하지 않고, 새 값을 만들어 교체해야 합니다. React는 이전 state와 새 state를 참조 비교(===)로 변경 여부를 판단하기 때문입니다.
객체 State 불변 업데이트
import { useState } from 'react';
function UserForm() {
const [user, setUser] = useState({
name: '김개발',
email: 'dev@example.com',
address: {
city: '서울',
district: '강남구',
},
});
// ❌ 잘못된 방법 — 직접 수정 (React가 변경을 감지 못할 수 있음)
function badUpdate() {
user.name = '박프론트'; // 직접 변경 — 금지!
setUser(user); // 참조가 같아서 리렌더링 안 될 수 있음
}
// ✅ 올바른 방법 — 스프레드로 새 객체 생성
function updateName(newName) {
setUser({ ...user, name: newName }); // 나머지 필드 유지, name만 교체
}
// ✅ 중첩 객체 업데이트 — 각 레벨마다 스프레드
function updateCity(newCity) {
setUser({
...user,
address: {
...user.address, // 기존 address 필드 유지
city: newCity, // city만 교체
},
});
}
return (
<div>
<p>{user.name} | {user.address.city}</p>
<button onClick={() => updateName('박프론트')}>이름 변경</button>
<button onClick={() => updateCity('부산')}>도시 변경</button>
</div>
);
}
배열 State 불변 업데이트
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'React 공부', done: false },
{ id: 2, text: 'TypeScript 공부', done: false },
]);
// 추가 — 스프레드로 새 배열 생성
function addTodo(text) {
const newTodo = { id: Date.now(), text, done: false };
setTodos([...todos, newTodo]);
}
// 삭제 — filter로 새 배열 생성
function deleteTodo(id) {
setTodos(todos.filter(todo => todo.id !== id));
}
// 수정 — map으로 새 배열 생성
function toggleTodo(id) {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}
// ❌ 절대 금지 — 배열 직접 수정 메서드
// todos.push(newTodo) — 원본 배열 변경
// todos.splice(0, 1) — 원본 배열 변경
// todos[0].done = true — 원본 객체 변경
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => toggleTodo(todo.id)}>완료</button>
<button onClick={() => deleteTodo(todo.id)}>삭제</button>
</li>
))}
</ul>
);
}
📖 핵심 개념 2 — 함수형 업데이트
setState에 값 대신 함수를 전달하면, React가 최신 state를 인자로 넘겨줍니다. 이전 state를 기반으로 새 state를 계산할 때 필수적입니다.
function Counter() {
const [count, setCount] = useState(0);
// ❌ 잘못된 방법 — 클로저 캡처 문제
function addThreeBad() {
setCount(count + 1); // count는 현재 클로저의 값 (예: 0)
setCount(count + 1); // 여전히 0 + 1 = 1
setCount(count + 1); // 여전히 0 + 1 = 1 → 결과: 1
}
// ✅ 올바른 방법 — 함수형 업데이트
function addThreeGood() {
setCount(prev => prev + 1); // 최신 state 기반
setCount(prev => prev + 1); // 이전 결과 기반
setCount(prev => prev + 1); // 이전 결과 기반 → 결과: 3
}
// 함수형 업데이트가 필요한 또 다른 경우: 타이머
function startAutoIncrement() {
const timer = setInterval(() => {
// 타이머 콜백 안의 count는 클로저로 고정됨
// 반드시 함수형 업데이트 사용
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}
return (
<div>
<p>카운트: {count}</p>
<button onClick={addThreeGood}>+3</button>
</div>
);
}
패널에서 “타이머, 소켓 이벤트 등 비동기 컨텍스트에서 state를 업데이트할 때는 항상 함수형 업데이트를 기본으로 사용하라”고 권고했습니다.
📖 핵심 개념 3 — React 18 자동 배칭
배칭(Batching)이란 여러 state 업데이트를 하나의 리렌더링으로 묶는 최적화입니다. React 18 이전에는 이벤트 핸들러 안에서만 배칭이 됐지만, React 18부터는 모든 비동기 컨텍스트에서도 자동 배칭이 됩니다.
// React 18 — 자동 배칭 (Automatic Batching)
function AutoBatchingDemo() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
// 이벤트 핸들러 안에서는 항상 배칭됨 (React 17, 18 모두)
function handleClick() {
setCount(c => c + 1); // 리렌더링 안 함
setFlag(f => !f); // 리렌더링 안 함
// 여기서 딱 한 번만 리렌더링
}
// React 18: setTimeout 안에서도 자동 배칭
function handleAsyncClick() {
setTimeout(() => {
setCount(c => c + 1); // React 17: 리렌더링 발생
setFlag(f => !f); // React 17: 리렌더링 발생 (총 2번)
// React 18: 딱 한 번만 리렌더링
}, 1000);
}
// 배칭을 원하지 않을 때: flushSync (드문 경우)
// import { flushSync } from 'react-dom';
// flushSync(() => setCount(c => c + 1)); // 즉시 리렌더링 강제
return <button onClick={handleClick}>count: {count}, flag: {String(flag)}</button>;
}
📖 핵심 개념 4 — 이벤트 처리
function EventDemo() {
// 폼 제출 기본 동작 방지
function handleSubmit(e) {
e.preventDefault(); // 페이지 새로고침 방지
console.log('폼 제출됨');
}
// 이벤트 버블링 방지
function handleChildClick(e) {
e.stopPropagation(); // 부모 요소로 이벤트 전파 차단
console.log('자식 클릭');
}
function handleParentClick() {
console.log('부모 클릭'); // stopPropagation 시 호출 안 됨
}
// 입력값 가져오기
function handleChange(e) {
console.log(e.target.value); // 입력값
console.log(e.target.name); // input의 name 속성
console.log(e.target.checked); // 체크박스 여부
}
return (
<form onSubmit={handleSubmit}>
<input
name="username"
onChange={handleChange}
placeholder="사용자명"
/>
<div onClick={handleParentClick} style={{ padding: '20px', background: '#eee' }}>
부모 영역
<button type="button" onClick={handleChildClick}>
자식 버튼 (버블링 차단)
</button>
</div>
<button type="submit">제출</button>
</form>
);
}
⚠️ 흔한 실수 (よくあるミス)
- state 직접 수정:
state.items.push(item)— React가 변경을 감지하지 못해 UI가 업데이트되지 않습니다. 항상 새 배열/객체를 생성하세요. - 연속 setState에서 이전 값 참조:
setCount(count + 1); setCount(count + 1);는 두 번 더해지지 않습니다. 함수형 업데이트를 사용하세요. - 이벤트 핸들러에 함수 호출 전달:
onClick={handleClick()}은 렌더링 시 즉시 실행됩니다.onClick={handleClick}처럼 함수 참조를 전달해야 합니다. - 불필요한 state: 다른 state나 props로 계산 가능한 값은 state로 만들지 마세요. 파생 값은 렌더링 중에 계산합니다.
💡 실무 팁
- Immer 라이브러리: 깊은 중첩 객체의 불변 업데이트가 복잡해질 때 Immer를 사용하면 직접 수정하는 문법으로 불변 업데이트를 작성할 수 있습니다.
- state 초기화 패턴: 폼 전체를 하나의 객체 state로 관리하면 리셋이 쉽습니다:
setForm(initialState) - controlled vs uncontrolled: React에서 폼 입력은
value와onChange로 제어하는 controlled 방식을 권장합니다. uncontrolled(ref사용)는 파일 입력 등 특수한 경우에만 사용합니다.