React 컴포넌트와 상태
5주 props·state·이벤트·안정적인 key로 명단 추가·검색·삭제 앱 완성하기
데이터에서 화면을 계산하기
이전 실습에서는 DOM 요소를 직접 만들어 목록에 붙였습니다. React에서는 “현재 이름 배열과 검색어라면 어떤 목록이 보여야 하는가”를 함수로 표현합니다. 상태가 달라지면 React가 다시 계산하고 필요한 DOM 변경을 반영합니다.
이번 결과물은 추가·검색·삭제할 수 있는 아기사자 명단입니다. JavaScript 배열·함수·이벤트를 알고 시작합니다. TypeScript는 8주 문서에서 다루므로 먼저 JSX로 진행합니다.
React 프로젝트 실행
Node.js LTS와 npm을 준비하고 새 실습 폴더를 만듭니다. 팀 저장소가 이미 있다면 생성 명령을 반복하지 말고 README와 lock 파일을 따릅니다.
npm create vite@latest lion-track -- --template reactcd lion-tracknpm installnpm run dev터미널에 표시된 주소를 엽니다. src/main.jsx는 유지하고 기본 화면의 CSS가 실습을 가리지 않도록 src/index.css의 예제 스타일을 정리합니다.
컴포넌트·props·state의 역할
| 개념 | 책임 | 명단 예제 |
|---|---|---|
| 컴포넌트 | UI 한 역할을 표현하는 함수 | MemberCard |
| props | 부모가 전달하는 읽기 전용 입력 | member, onRemove |
| state | 입력에 따라 기억할 최소 값 | members, query, name |
| 파생 값 | state에서 계산할 수 있는 값 | 검색 결과, 결과 개수 |
필터된 배열을 별도 state에 저장하지 않습니다. 원본 목록과 query에서 계산할 수 있기 때문입니다. 같은 정보를 두 군데 저장하면 “추가했는데 검색 결과에는 안 보이는” 불일치가 생깁니다.
실행 가능한 전체 예제
src/App.jsx를 다음으로 바꿉니다.
import { useState } from "react";
function MemberCard({ member, onRemove }) { return <li> <span>{member.name}</span> <button type="button" onClick={() => onRemove(member.id)} aria-label={member.name + " 삭제"}>삭제</button> </li>;}
export default function App() { const [members, setMembers] = useState([ { id: "1", name: "아기사자 A" }, { id: "2", name: "아기사자 B" }, ]); const [name, setName] = useState(""); const [query, setQuery] = useState(""); const [error, setError] = useState(""); const filtered = members.filter(member => member.name.toLowerCase().includes(query.trim().toLowerCase()) ); function addMember(event) { event.preventDefault(); const value = name.trim(); if (!value || value.length > 40) { setError("이름을 1~40자로 입력하세요."); return; } const member = { id: crypto.randomUUID(), name: value }; setMembers(current => [...current, member]); setName(""); setError(""); } function removeMember(id) { setMembers(current => current.filter(member => member.id !== id)); } return <main> <h1>아기사자 명단</h1> <form onSubmit={addMember}> <label htmlFor="name">추가할 이름</label> <input id="name" value={name} maxLength={40} aria-invalid={Boolean(error)} aria-describedby="name-error" onChange={event => setName(event.target.value)} /> <button>추가</button> <p id="name-error" role="alert">{error}</p> </form> <label htmlFor="query">이름 검색</label> <input id="query" value={query} onChange={event => setQuery(event.target.value)} /> <p aria-live="polite">{filtered.length}명</p> {filtered.length ? <ul>{filtered.map(member => <MemberCard key={member.id} member={member} onRemove={removeMember} /> )}</ul> : <p>결과가 없습니다. 검색어를 바꾸거나 이름을 추가하세요.</p>} </main>;}crypto.randomUUID는 localhost 또는 HTTPS 같은 보안 컨텍스트(secure context)에서 사용합니다. 데이터는 메모리에 있어 새로고침하면 초기화됩니다. 영속 저장은 Supabase 문서에서 추가합니다.
코드의 흐름과 상태 스냅샷
App은 현재 state로 JSX를 계산합니다. onChange는 setter에 다음 값을 전달하며 DOM을 직접 수정하지 않습니다. 제출하면 기본 새로고침을 막고 입력을 검증합니다. 원본을 push하는 대신 새 배열을 반환하면 새로운 members로 검색 결과와 개수가 계산됩니다.
key는 이름이 아니라 항목의 정체성입니다. 같은 이름도 서로 다른 사람이므로 ID를 사용합니다. 렌더링마다 새 ID를 만들지 말고 항목 생성 시 한 번만 만듭니다.
setter를 호출해도 현재 이벤트 핸들러가 읽는 변수는 그 렌더링의 값입니다. 아래는 설명용 코드입니다.
setCount(count + 1);setCount(count + 1);// 같은 count를 읽으므로 +2를 기대하면 안 됩니다.
setCount(current => current + 1);setCount(current => current + 1);// 이전 업데이트 결과를 다음 업데이트의 입력으로 받습니다.자식은 props를 직접 수정하지 않고 부모가 전달한 함수를 호출합니다. 여러 컴포넌트가 같은 값을 사용하면 가능한 가까운 공통 부모로 state를 올립니다. 실제 전달 문제가 나타나기 전에 Context를 도입할 필요는 없습니다.
세션 실습과 완료 기준
90분: 정적 카드 분리 20분, 추가 폼 25분, 검색·삭제 30분, 짝 리뷰 15분. 단계마다 실행 결과와 작은 커밋을 남깁니다. 전체 코드는 막혔을 때 비교할 기준입니다.
- MemberCard만 먼저 출력하고 부모가 이름 두 개를 전달하게 합니다.
- 추가 폼을 연결합니다. Enter와 클릭 모두 동작하고 공백 입력은 거부되어야 합니다.
- 같은 이름을 두 번 추가한 뒤 하나만 삭제합니다.
- 검색 중 삭제해 원본·결과·개수가 일치하는지 확인합니다.
- 트랙 필드를 추가하고 트랙별 개수를 별도 state 없이 계산합니다.
완료 증거는 정상·빈 결과·오류 화면, 입력과 삭제의 재현 단계, state 소유권을 설명한 PR입니다.
질문: name과 query는 왜 state이고 filtered는 왜 일반 변수인가요? 배열 인덱스 key가 삭제 가능한 목록에서 문제가 되는 이유는 무엇인가요? 부모 목록을 자식에서 직접 변경하면 어떤 계약이 깨지나요?
더 읽어보기
연결된 PBL 미션과 VOD
| 주차·미션 | 참고 VOD 범위 |
|---|---|
| 5주 · React 컴포넌트 | 리액트 실무 4장 |
강좌 안내: 참고 강좌 1 · 참고 강좌 2. 이 문서는 영상 전체를 옮긴 전사 자료가 아닙니다. 세부 내용은 위 공식 문서에서 확인합니다.