기초

React 강좌 04강 — useState와 이벤트 처리: 반응형 UI 만들기

🎯 학습 목표

  • 객체·배열 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에서 폼 입력은 valueonChange로 제어하는 controlled 방식을 권장합니다. uncontrolled(ref 사용)는 파일 입력 등 특수한 경우에만 사용합니다.