데이터 흐름과 상태 관리: Props와 useState 마스터
리액트의 핵심인 단방향 데이터 흐름(One-way Data Flow)을 이해하고, 부모-자식 컴포넌트 간에 데이터를 전달하는 방법(Props)과 컴포넌트 내부에서 변경되는 데이터(State)를 관리하는 방법을 마스터합니다. '상태 끌어올리기(Lifting State Up)' 패턴과 배열/객체 상태의 불변성(Immutability) 유지 방법도 함께 익힙니다.
📌 1. 단방향 데이터 흐름 (One-way Data Flow)
리액트에서 데이터는 항상 위에서 아래로(부모 컴포넌트 -> 자식 컴포넌트)만 흐릅니다. 부모가 가진 데이터를 자식에게 물려주는 매개체를 Props라고 합니다.
- Props는 읽기 전용(Read-only): 자식 컴포넌트는 부모로부터 전달받은 Props를 직접 수정할 수 없습니다. UI를 그리기 위한 '설명서'처럼 다루어야 합니다.
- 상태 끌어올리기 (Lifting State Up): 자식 컴포넌트 내부에서 부모의 상태를 변경해야 하는 경우 어떻게 할까요? 위 다이어그램처럼 부모 컴포넌트가 '상태를 변경하는 함수(
setState)'를 자식에게 Props로 넘겨주고, 자식이 버튼 클릭 등의 이벤트가 발생했을 때 이 함수를 대신 호출하는 방식을 사용합니다.
📌 2. 왜 불변성(Immutability)을 지켜야 할까요?
useState를 다룰 때, 특히 객체나 배열의 경우 push()나 obj.key = value와 같이 원본 자체를 수정하면 안 됩니다. 리액트는 성능 최적화를 위해 객체의 모든 속성을 하나하나 비교하지 않고, 메모리의 주소값(참조값)이 바뀌었는지만 확인하는 '얕은 비교(Shallow Compare)'를 수행하기 때문입니다.
💡 중요:
push()를 사용해 배열에 요소를 추가하면, 배열의 내용물은 바뀌었지만 주소값은 그대로이기 때문에 리액트는 "상태가 바뀌지 않았다"고 판단하여 화면을 다시 그리지 않습니다.따라서,
...(스프레드 연산자)나filter(),map()등을 활용해 완전히 새로운 객체/배열을 만들어setState에 전달해야 합니다.
import React, { useState } from 'react';
import { UserCard } from './UserCard';
import { Container } from './Container';
import { CounterButton } from './CounterButton';
// 부모 컴포넌트 (상태 관리자)
export function PropsExample() {
// 상태 끌어올리기 (Lifting State Up)
// 자식 컴포넌트들이 공유해야 할 상태는 부모가 가지고 있어야 합니다.
const [globalCount, setGlobalCount] = useState(0);
return (
<Container title={"팀원 목록 (총 " + globalCount + "명 조회)"}>
<UserCard name="홍길동" job="프론트엔드 개발자" />
<UserCard name="김철수" /> {/* job을 안 넘기면 기본값이 들어갑니다 */}
{/* 함수를 Props로 전달하여 자식이 부모의 상태를 바꿀 수 있게 합니다 */}
<CounterButton onIncrease={() => setGlobalCount(prev => prev + 1)} />
</Container>
);
}ComplexStateExample.jsx
import React, { useState } from 'react';
export function ComplexStateExample() {
// 1. 객체 상태 관리 (회원가입 폼)
const [form, setForm] = useState({ username: '', email: '' });
// 2. 배열 상태 관리 (할 일 목록)
const [todos, setTodos] = useState(['리액트 복습하기', '운동하기']);
// 객체 상태 업데이트 함수
const handleFormChange = (e) => {
const { name, value } = e.target;
// 💡 객체의 불변성을 지키기 위해 기존 객체(...form)를 복사한 뒤 변경할 속성만 덮어씌웁니다.
setForm(prevForm => ({
...prevForm,
[name]: value
}));
};
// 배열 상태 업데이트 함수 (추가)
const addTodo = () => {
if (!form.username) return alert('이름을 먼저 입력해주세요!');
const newTodo = form.username + "님의 새로운 할 일";
// 💡 배열의 불변성을 지키기 위해 기존 배열(...todos)을 복사하고 새 항목을 추가합니다. (push 사용 금지)
setTodos(prevTodos => [...prevTodos, newTodo]);
};
// 배열 상태 업데이트 함수 (삭제)
const deleteTodo = (indexToRemove) => {
// 💡 filter를 사용해 특정 인덱스를 제외한 새로운 배열을 반환합니다.
setTodos(prevTodos => prevTodos.filter((_, index) => index !== indexToRemove));
};
return (
<div style={{ background: '#f8fafc', padding: '1.5rem', borderRadius: '8px', border: '1px solid #cbd5e1' }}>
{/* 입력 폼 (Two-way Data Binding) */}
<div style={{ marginBottom: '20px' }}>
<h4>✍️ 양식 입력 (객체 상태)</h4>
<input
type="text" name="username" value={form.username} onChange={handleFormChange}
placeholder="이름" style={{ padding: '8px', marginRight: '5px' }}
/>
<input
type="email" name="email" value={form.email} onChange={handleFormChange}
placeholder="이메일" style={{ padding: '8px' }}
/>
<p style={{ fontSize: '0.9rem', color: '#64748b' }}>현재 입력값: {form.username} / {form.email}</p>
</div>
<hr style={{ borderColor: '#e2e8f0' }} />
{/* 배열 리스트 */}
<div>
<h4>📝 할 일 목록 (배열 상태)</h4>
<button
onClick={addTodo}
style={{ padding: '6px 12px', background: '#10b981', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer', marginBottom: '10px' }}
>할 일 추가</button>
<ul style={{ paddingLeft: '20px' }}>
{todos.map((todo, index) => (
<li key={index} style={{ marginBottom: '8px' }}>
{todo}
<button onClick={() => deleteTodo(index)} style={{ marginLeft: '10px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>삭제</button>
</li>
))}
</ul>
</div>
</div>
);
}