minstudio

데이터 흐름과 상태 관리: Props와 useState 마스터

리액트의 핵심인 단방향 데이터 흐름(One-way Data Flow)을 이해하고, 부모-자식 컴포넌트 간에 데이터를 전달하는 방법(Props)과 컴포넌트 내부에서 변경되는 데이터(State)를 관리하는 방법을 마스터합니다. '상태 끌어올리기(Lifting State Up)' 패턴과 배열/객체 상태의 불변성(Immutability) 유지 방법도 함께 익힙니다.
<PropsExample /> (Parent Component) globalCount (State) setGlobalCount() Props (Data) Props (Callback) Event Triggers <UserCard /> (Child) Reads Props (Read-only) <CounterButton /> (Child) Calls onIncrease() One-way Data Flow & Lifting State Up

📌 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>
  );
}
실행 결과
데이터 흐름과 상태 관리: Props와 useState 마스터 | Minstudio