Skip to Content
WebReactReact 기초08. 이벤트와 useState로 별점·상태 토글

이번 편의 결과물: 카드에서 별을 클릭해 별점을 매기고, 상태 배지를 클릭해 읽기 상태를 바꿀 수 있습니다(각 카드 안에서만 반영). · 다루는 개념: 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·ratinginitialStatus·initialRating으로 이름을 바꿔 받고, 이 값을 각각 useState의 초깃값으로만 씁니다. 이후 화면에 보이는 값은 props가 아니라 BookCard가 스스로 관리하는 state(status, rating)입니다. StatusBadge를 클릭하면 onToggle(nextStatus(status))BookCardsetStatus를 호출하고, StarRating의 별을 클릭하면 onChange(starValue)setRating을 호출합니다. 두 state 모두 BookCard 안에 있으므로 다른 책 카드에는 영향을 주지 않습니다.

3. 실행

npm run dev

4. 확인

  • 아무 카드에서 별을 클릭하면 클릭한 별까지 채워지고, 그 카드의 별점만 바뀝니다. 다른 카드의 별점은 그대로입니다.
  • 상태 배지를 클릭할 때마다 “읽고 싶음 → 읽는 중 → 읽음 → 읽고 싶음” 순서로 문구와 색이 바뀝니다.
  • 페이지를 새로고침하면 클릭으로 바꾼 값은 모두 books.seed.json의 원래 값으로 돌아갑니다(아직 저장하지 않기 때문입니다. localStorage 저장은 11편에서 다룹니다).

직접 해보기

  1. StarRating에 별 위에 마우스를 올리면(hover) 그 위치까지 미리보기로 채워지는 기능을 useState로 추가해봅니다(힌트: hoverValue state를 하나 더 두고, onMouseEnter/onMouseLeave로 값을 바꿉니다).
  2. BookCard에 “초기화” 버튼을 추가해 클릭하면 ratingstatus를 각각 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보다 큰 값)를 우선 쓰고, 마우스가 벗어나 hoverValue0(falsy)이 되면 실제 rating을 보여줍니다.

자주 하는 실수

증상원인고치는 법
별을 클릭하면 즉시 모든 별이 채워짐(무한 렌더링 또는 즉시 실행)onClick={onChange(starValue)}처럼 함수를 호출한 결과를 전달onClick={() => onChange(starValue)}처럼 화살표 함수로 감싼다
별을 클릭해도 화면이 안 바뀜useState의 값을 직접 변수에 재대입(rating = 3)반드시 setRating(3)처럼 상태 갱신 함수를 호출한다
한 카드의 별점을 바꿨는데 다른 카드도 같이 바뀜rating state를 여러 카드가 공유하는 상위 컴포넌트에 잘못 둠카드마다 독립된 state가 필요하면 BookCard 내부에 useState를 둔다

확인 문제

문제 14지선다
useState를 호출하는 위치로 옳은 것은?
문제 24지선다
onClick={handleClick()} 과 onClick={handleClick} 의 차이는?
문제 34지선다
BookCard 안의 useState로 만든 rating state에 대한 설명으로 옳은 것은?
문제 44지선다
setRating(3)을 호출했을 때 일어나는 일로 옳은 것은?

참고 자료

Last updated on