이번 편의 결과물: 책 추가·삭제·별점 변경·상태 변경이 booksReducer 하나의 dispatch로 통합되어 처리됩니다. · 다루는 개념: reducer 함수, action 설계, useReducer, dispatch
이 편에서 만드는 파일
bookshelf/src/
├── reducers/
│ └── booksReducer.js + 신규
└── App.jsx ~ 수정 (useState → useReducer)개념 정리
지금까지 App은 books 배열 하나에 대해 handleAddBook, handleDeleteBook, handleRatingChange, handleStatusChange 네 개의 핸들러를 가지고 있습니다. 각 핸들러가 각자 setBooks를 부르고 각자 불변 업데이트를 반복합니다. 핸들러가 늘어날수록 “books가 대체 어떤 규칙으로 바뀌는가”라는 질문에 답하려면 파일 전체를 훑어야 합니다.
useReducer는 상태 변경 규칙을 한곳에 모으는 훅입니다.
const [state, dispatch] = useReducer(reducer, initialState);| 이름 | 역할 |
|---|---|
state | 현재 상태. useState의 상태 변수와 같은 자리 |
dispatch | 액션을 발송하는 함수. setState 자리를 대신함 |
reducer | (state, action)을 받아 다음 상태를 반환하는 순수 함수 |
initialState | 초기 상태 |
흐름은 이렇습니다.
- 이벤트 핸들러는 상태를 직접 바꾸지 않고
dispatch({ type: 'ADD_BOOK', book })처럼 “무슨 일이 일어났는지” 만 발송합니다. - React가 그 액션과 현재 상태를 들고 reducer 함수를 호출합니다.
- reducer가 계산한 다음 상태로 React가 상태를 교체하고 리렌더합니다.
액션은 type(사건 종류)과 처리에 필요한 최소 정보를 담은 객체입니다. “무엇을 하라”가 아니라 “무슨 일이 있었다”로 설계하는 것이 원칙입니다. reducer는 두 계약을 지켜야 합니다 — 순수 함수여야 하고(부수 효과 금지), 기존 상태를 직접 고치지 않고 새 배열·객체를 반환해야 합니다(불변 업데이트, 10편).
| 판단 질문 | 예 → useReducer | 아니오 → useState |
|---|---|---|
| 변경 경우의 수가 3–4가지를 넘는가 | 규칙을 한곳에 모을 가치 있음 | 핸들러 하나로 충분 |
| 여러 핸들러가 같은 배열을 각자 다르게 고치는가 | 액션으로 통일 | 공유할 것 없음 |
| 로직을 컴포넌트 밖에서 단독 테스트하고 싶은가 | 순수 함수라 테스트 용이 | 필요성 낮음 |
books는 추가·삭제·별점 변경·상태 변경 네 가지 경우가 있으므로 useReducer로 옮길 대상입니다. 반대로 Header의 theme, BookForm의 입력값처럼 변경 경우가 하나뿐인 상태는 useState로 남겨둡니다. 두 훅은 한 앱 안에서 자연스럽게 섞입니다.
실습
1. booksReducer.js 만들기
// src/reducers/booksReducer.js
export const initialBooksState = [];
export function booksReducer(books, action) {
switch (action.type) {
case 'ADD_BOOK':
return [...books, action.book];
case 'DELETE_BOOK':
return books.filter((book) => book.id !== action.id);
case 'SET_RATING':
return books.map((book) =>
book.id === action.id ? { ...book, rating: action.rating } : book,
);
case 'SET_STATUS':
return books.map((book) =>
book.id === action.id ? { ...book, status: action.status } : book,
);
default:
throw new Error(`알 수 없는 액션 타입입니다: ${action.type}`);
}
}booksReducer는 React를 몰라도 되는 순수 자바스크립트 함수입니다. books 상태가 바뀔 수 있는 경우 네 가지가 이 함수 하나에 전부 나열되어 있습니다.
2. App.jsx를 useReducer로 전환하기
// src/App.jsx
import { useReducer, useEffect, useRef } from 'react';
import initialBooks from './data/books.seed.json';
import { booksReducer } from './reducers/booksReducer';
import Header from './components/Header';
import BookForm from './components/BookForm';
import BookList from './components/BookList';
function loadInitialState() {
const saved = localStorage.getItem('bookshelf-books');
return saved ? JSON.parse(saved) : initialBooks;
}
function App() {
const [books, dispatch] = useReducer(booksReducer, undefined, loadInitialState);
const scrollTargetRef = useRef(null);
useEffect(() => {
localStorage.setItem('bookshelf-books', JSON.stringify(books));
document.title = `bookshelf (${books.length}권)`;
}, [books]);
useEffect(() => {
scrollTargetRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [books.length]);
function handleAddBook(newBook) {
dispatch({ type: 'ADD_BOOK', book: newBook });
}
function handleDeleteBook(id) {
dispatch({ type: 'DELETE_BOOK', id });
}
function handleRateBook(id, rating) {
dispatch({ type: 'SET_RATING', id, rating });
}
function handleToggleStatus(id, status) {
dispatch({ type: 'SET_STATUS', id, status });
}
return (
<div className="app">
<Header />
<p>총 {books.length}권</p>
<BookList
ref={scrollTargetRef}
books={books}
onDelete={handleDeleteBook}
onRate={handleRateBook}
onToggleStatus={handleToggleStatus}
/>
<BookForm onAddBook={handleAddBook} />
</div>
);
}
export default App;useReducer(booksReducer, undefined, loadInitialState)의 세 번째 인자는 초기화 함수입니다. React는 최초 렌더에서 딱 한 번만 이 함수를 호출해 초기 상태를 계산합니다. localStorage 읽기처럼 계산 비용이 있는 초기화는 이렇게 세 번째 인자로 넘겨야, 매 렌더마다 다시 읽는 낭비를 피할 수 있습니다.
핸들러 본문을 눈여겨보세요. 네 핸들러 모두 “무슨 일이 있었는지” 한 줄을 dispatch로 보내는 것이 전부입니다. 불변 업데이트 코드는 전부 booksReducer.js로 이사했습니다. 포커스·스크롤(12편)과 필터(13편)는 그대로 유지됩니다 — BookForm은 여전히 자기 안의 titleInputRef로 제출 후 포커스를 되돌리고, scrollTargetRef는 12편처럼 BookList의 forwardRef에 연결됩니다.
3. BookCard·StatusBadge·StarRating은 그대로 둔다
이 컴포넌트들은 onDelete, onRate, onToggleStatus를 props로 받아 호출할 뿐, books 상태가 useState인지 useReducer인지 알 필요가 없습니다. props 인터페이스가 그대로이므로 수정할 필요가 없습니다.
4. 실행
npm run dev5. 확인
- 책 추가·삭제·별점 변경·상태 변경이 이전과 동일하게 동작합니다(사용자 눈에는 차이가 없어야 정상입니다).
- React DevTools의 Components 탭에서
App을 선택하면 hooks 목록에Reducer가 표시되고, 값으로 현재books배열을 볼 수 있습니다. - 콘솔에서 존재하지 않는 액션 타입을 임시로
dispatch({ type: 'UNKNOWN' })처럼 호출해 보면(브라우저 콘솔에서 직접 호출은 어려우므로, 코드에 임시로 넣어 테스트)알 수 없는 액션 타입입니다에러가 던져지는지 확인합니다. 확인 후 테스트 코드는 제거합니다.
직접 해보기
booksReducer에'DELETE_ALL_DONE'액션을 추가해,status가'done'인 책을 한 번에 모두 지우는 기능을 만들어 보세요.Header나 별도 버튼에서dispatch({ type: 'DELETE_ALL_DONE' })을 호출하도록 연결해 보세요.
정답 보기
// src/reducers/booksReducer.js (case 추가)
case 'DELETE_ALL_DONE':
return books.filter((book) => book.status !== 'done');App에서 dispatch를 Header로 내려주거나, App 안에 버튼을 하나 추가해 onClick={() => dispatch({ type: 'DELETE_ALL_DONE' })}으로 연결합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 화면이 갱신되지 않음 | reducer 안에서 books.push(...)처럼 기존 배열을 직접 수정 | 전개 구문·map·filter로 새 배열을 반환 |
dispatch 직후 books를 읽었는데 이전 값 | 상태는 다음 렌더에서 갱신되는 스냅샷 | 최신 값이 필요하면 다음 렌더의 JSX나 useEffect에서 확인 |
useReducer의 초기화 함수가 렌더마다 다시 실행되는 것처럼 보임 | 세 번째 인자 없이 useReducer(booksReducer, loadInitialState())처럼 즉시 호출 | useReducer(booksReducer, undefined, loadInitialState)로 함수 자체를 넘김 |
| 알 수 없는 액션 타입 에러가 자주 발생 | 오타로 type 문자열이 reducer의 case와 불일치 | 액션 타입 문자열을 상수로 뽑아 오타 방지(선택 사항) |