이번 편의 결과물: 카드에서 별을 클릭해 별점을 매기고, 상태 배지를 클릭해 읽기 상태를 바꿀 수 있습니다(각 카드 안에서만 반영). · 다루는 개념: onClick 이벤트 핸들러, useState로 컴포넌트별 로컬 상태 관리, 상태 변경이 리렌더를 일으키는 원리
이 편에서 만드는 파일
bookshelf/src/
├── components/
│ ├── StarRating.jsx (+)
│ ├── StatusBadge.jsx (~)
│ └── BookCard.jsx (~)
└── App.jsx (변경 없음)개념 정리
지금까지 화면에 보이는 값은 전부 books.seed.json에서 props로 내려온 값이었습니다. 클릭해서 값을 바꾸려면 컴포넌트가 자기만의 기억을 가져야 합니다. 이 기억이 state입니다.
useState 기본형
const [값, 값을바꾸는함수] = useState(초깃값);| 요소 | 역할 |
|---|---|
초깃값 | 컴포넌트가 처음 렌더링될 때만 사용되는 값 |
값 | 현재 렌더링에서 읽는 state 값 |
값을바꾸는함수 | 호출하면 state를 갱신하고 컴포넌트를 다시 렌더링시키는 함수(관례상 set이름 형태로 짓는다) |
useState는 컴포넌트 함수 본문의 최상위에서만 호출합니다. if문이나 반복문, 중첩 함수 안에서 호출하면 안 됩니다. React는 호출 순서로 각 state를 구분하기 때문에, 조건에 따라 호출 여부가 달라지면 순서가 어긋납니다.
state 변경이 리렌더를 일으키는 이유
setRating(3)처럼 값을 바꾸는 함수를 호출하면 React는 그 컴포넌트를 다시 실행해 새 JSX를 계산하고, 이전 결과와 비교해 바뀐 부분만 실제 DOM에 반영합니다. 이때 중요한 점이 두 가지 있습니다.
- 새 값을 넘겨야 리렌더가 일어난다.
setRating(rating)처럼 이전과 같은 값을 넘기면 React가 렌더링을 건너뛸 수 있습니다. - 한 컴포넌트의 state는 그 컴포넌트와 그 자식에게만 영향을 준다.
BookCard하나에서setRating을 호출해도 다른BookCard는 리렌더되지 않습니다. 그래서 이번 편의 별점·상태 토글은 카드마다 독립적으로 동작합니다.
이벤트 핸들러 연결
JSX의 onClick에는 함수 자체를 전달합니다. onClick={handleClick}처럼 함수 참조를 넘기고, onClick={handleClick()}처럼 호출한 결과를 넘기면 렌더링 시점에 즉시 실행되어버려 원하는 동작이 되지 않습니다.
<button onClick={() => setRating(3)}>3점</button>인자를 넘겨야 할 때는 화살표 함수로 감싸 onClick={() => setRating(3)}처럼 씁니다.
실습
1. StarRating.jsx 만들기
src/components/StarRating.jsx를 새로 만듭니다.
2. 코드 작성
// src/components/StarRating.jsx
const STAR_COUNT = 5;
function StarRating({ rating, onChange }) {
return (
<div className="star-rating" role="group" aria-label="별점">
{Array.from({ length: STAR_COUNT }, (_, index) => {
const starValue = index + 1;
const filled = starValue <= rating;
return (
<button
key={starValue}
type="button"
className={filled ? 'star filled' : 'star'}
onClick={() => onChange(starValue)}
aria-label={`${starValue}점`}
>
{filled ? '★' : '☆'}
</button>
);
})}
</div>
);
}
export default StarRating;// src/components/StatusBadge.jsx
const STATUS_LABEL = {
wish: '읽고 싶음',
reading: '읽는 중',
done: '읽음',
};
const STATUS_COLOR = {
wish: '#6b7280',
reading: '#2563eb',
done: '#16a34a',
};
const STATUS_ORDER = ['wish', 'reading', 'done'];
function nextStatus(status) {
const currentIndex = STATUS_ORDER.indexOf(status);
const nextIndex = (currentIndex + 1) % STATUS_ORDER.length;
return STATUS_ORDER[nextIndex];
}
function StatusBadge({ status, onToggle }) {
const label = STATUS_LABEL[status] ?? '알 수 없음';
const color = STATUS_COLOR[status] ?? '#9ca3af';
return (
<button
type="button"
className="status-badge"
style={{ backgroundColor: color }}
onClick={() => onToggle(nextStatus(status))}
>
{label}
</button>
);
}
export default StatusBadge;// src/components/BookCard.jsx
import { useState } from 'react';
import StatusBadge from './StatusBadge.jsx';
import StarRating from './StarRating.jsx';
function BookCard({ title, author, coverColor, pages, status: initialStatus, rating: initialRating }) {
const [status, setStatus] = useState(initialStatus);
const [rating, setRating] = useState(initialRating);
return (
<article className="book-card" style={{ borderTop: `6px solid ${coverColor}` }}>
<StatusBadge status={status} onToggle={setStatus} />
<h2>{title}</h2>
<p>{author}</p>
<p className="book-pages">{pages}쪽</p>
<StarRating rating={rating} onChange={setRating} />
</article>
);
}
export default BookCard;BookCard는 props로 받은 status·rating을 initialStatus·initialRating으로 이름을 바꿔 받고, 이 값을 각각 useState의 초깃값으로만 씁니다. 이후 화면에 보이는 값은 props가 아니라 BookCard가 스스로 관리하는 state(status, rating)입니다. StatusBadge를 클릭하면 onToggle(nextStatus(status))가 BookCard의 setStatus를 호출하고, StarRating의 별을 클릭하면 onChange(starValue)가 setRating을 호출합니다. 두 state 모두 BookCard 안에 있으므로 다른 책 카드에는 영향을 주지 않습니다.
3. 실행
npm run dev4. 확인
- 아무 카드에서 별을 클릭하면 클릭한 별까지 채워지고, 그 카드의 별점만 바뀝니다. 다른 카드의 별점은 그대로입니다.
- 상태 배지를 클릭할 때마다 “읽고 싶음 → 읽는 중 → 읽음 → 읽고 싶음” 순서로 문구와 색이 바뀝니다.
- 페이지를 새로고침하면 클릭으로 바꾼 값은 모두
books.seed.json의 원래 값으로 돌아갑니다(아직 저장하지 않기 때문입니다.localStorage저장은 11편에서 다룹니다).
직접 해보기
StarRating에 별 위에 마우스를 올리면(hover) 그 위치까지 미리보기로 채워지는 기능을useState로 추가해봅니다(힌트:hoverValuestate를 하나 더 두고,onMouseEnter/onMouseLeave로 값을 바꿉니다).BookCard에 “초기화” 버튼을 추가해 클릭하면rating과status를 각각initialRating,initialStatus로 되돌리는 기능을 만들어봅니다.
정답 보기
// StarRating.jsx
import { useState } from 'react';
function StarRating({ rating, onChange }) {
const [hoverValue, setHoverValue] = useState(0);
return (
<div className="star-rating" role="group" aria-label="별점">
{Array.from({ length: STAR_COUNT }, (_, index) => {
const starValue = index + 1;
const filled = starValue <= (hoverValue || rating);
return (
<button
key={starValue}
type="button"
className={filled ? 'star filled' : 'star'}
onClick={() => onChange(starValue)}
onMouseEnter={() => setHoverValue(starValue)}
onMouseLeave={() => setHoverValue(0)}
aria-label={`${starValue}점`}
>
{filled ? '★' : '☆'}
</button>
);
})}
</div>
);
}// BookCard.jsx 안에 버튼 추가
<button
type="button"
onClick={() => {
setStatus(initialStatus);
setRating(initialRating);
}}
>
초기화
</button>hoverValue || rating은 마우스가 올라가 있으면 hoverValue(0보다 큰 값)를 우선 쓰고, 마우스가 벗어나 hoverValue가 0(falsy)이 되면 실제 rating을 보여줍니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 별을 클릭하면 즉시 모든 별이 채워짐(무한 렌더링 또는 즉시 실행) | onClick={onChange(starValue)}처럼 함수를 호출한 결과를 전달 | onClick={() => onChange(starValue)}처럼 화살표 함수로 감싼다 |
| 별을 클릭해도 화면이 안 바뀜 | useState의 값을 직접 변수에 재대입(rating = 3) | 반드시 setRating(3)처럼 상태 갱신 함수를 호출한다 |
| 한 카드의 별점을 바꿨는데 다른 카드도 같이 바뀜 | rating state를 여러 카드가 공유하는 상위 컴포넌트에 잘못 둠 | 카드마다 독립된 state가 필요하면 BookCard 내부에 useState를 둔다 |