Skip to Content
WebReactReact 기초14. useReducer로 책 목록 CRUD 통합

이번 편의 결과물: 책 추가·삭제·별점 변경·상태 변경이 booksReducer 하나의 dispatch로 통합되어 처리됩니다. · 다루는 개념: reducer 함수, action 설계, useReducer, dispatch

이 편에서 만드는 파일

bookshelf/src/ ├── reducers/ │ └── booksReducer.js + 신규 └── App.jsx ~ 수정 (useState → useReducer)

개념 정리

지금까지 Appbooks 배열 하나에 대해 handleAddBook, handleDeleteBook, handleRatingChange, handleStatusChange 네 개의 핸들러를 가지고 있습니다. 각 핸들러가 각자 setBooks를 부르고 각자 불변 업데이트를 반복합니다. 핸들러가 늘어날수록 “books가 대체 어떤 규칙으로 바뀌는가”라는 질문에 답하려면 파일 전체를 훑어야 합니다.

useReducer는 상태 변경 규칙을 한곳에 모으는 훅입니다.

const [state, dispatch] = useReducer(reducer, initialState);
이름역할
state현재 상태. useState의 상태 변수와 같은 자리
dispatch액션을 발송하는 함수. setState 자리를 대신함
reducer(state, action)을 받아 다음 상태를 반환하는 순수 함수
initialState초기 상태

흐름은 이렇습니다.

  1. 이벤트 핸들러는 상태를 직접 바꾸지 않고 dispatch({ type: 'ADD_BOOK', book })처럼 “무슨 일이 일어났는지” 만 발송합니다.
  2. React가 그 액션과 현재 상태를 들고 reducer 함수를 호출합니다.
  3. reducer가 계산한 다음 상태로 React가 상태를 교체하고 리렌더합니다.

액션은 type(사건 종류)과 처리에 필요한 최소 정보를 담은 객체입니다. “무엇을 하라”가 아니라 “무슨 일이 있었다”로 설계하는 것이 원칙입니다. reducer는 두 계약을 지켜야 합니다 — 순수 함수여야 하고(부수 효과 금지), 기존 상태를 직접 고치지 않고 새 배열·객체를 반환해야 합니다(불변 업데이트, 10편).

판단 질문예 → useReducer아니오 → useState
변경 경우의 수가 3–4가지를 넘는가규칙을 한곳에 모을 가치 있음핸들러 하나로 충분
여러 핸들러가 같은 배열을 각자 다르게 고치는가액션으로 통일공유할 것 없음
로직을 컴포넌트 밖에서 단독 테스트하고 싶은가순수 함수라 테스트 용이필요성 낮음

books는 추가·삭제·별점 변경·상태 변경 네 가지 경우가 있으므로 useReducer로 옮길 대상입니다. 반대로 Headertheme, 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편처럼 BookListforwardRef에 연결됩니다.

3. BookCard·StatusBadge·StarRating은 그대로 둔다

이 컴포넌트들은 onDelete, onRate, onToggleStatus를 props로 받아 호출할 뿐, books 상태가 useState인지 useReducer인지 알 필요가 없습니다. props 인터페이스가 그대로이므로 수정할 필요가 없습니다.

4. 실행

npm run dev

5. 확인

  • 책 추가·삭제·별점 변경·상태 변경이 이전과 동일하게 동작합니다(사용자 눈에는 차이가 없어야 정상입니다).
  • React DevTools의 Components 탭에서 App을 선택하면 hooks 목록에 Reducer가 표시되고, 값으로 현재 books 배열을 볼 수 있습니다.
  • 콘솔에서 존재하지 않는 액션 타입을 임시로 dispatch({ type: 'UNKNOWN' })처럼 호출해 보면(브라우저 콘솔에서 직접 호출은 어려우므로, 코드에 임시로 넣어 테스트) 알 수 없는 액션 타입입니다 에러가 던져지는지 확인합니다. 확인 후 테스트 코드는 제거합니다.

직접 해보기

  1. booksReducer'DELETE_ALL_DONE' 액션을 추가해, status'done'인 책을 한 번에 모두 지우는 기능을 만들어 보세요.
  2. Header나 별도 버튼에서 dispatch({ type: 'DELETE_ALL_DONE' })을 호출하도록 연결해 보세요.

정답 보기

// src/reducers/booksReducer.js (case 추가) case 'DELETE_ALL_DONE': return books.filter((book) => book.status !== 'done');

App에서 dispatchHeader로 내려주거나, 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와 불일치액션 타입 문자열을 상수로 뽑아 오타 방지(선택 사항)

확인 문제

문제 14지선다
useState 대비 useReducer가 해결하는 핵심 문제는?
문제 24지선다
useReducer(reducer, initialState)가 반환하는 배열의 구성은?
문제 34지선다
booksReducer의 SET_RATING case에서 대상 id만 새 객체로 교체하고 나머지는 그대로 두는 map 패턴을 쓰는 이유는?
문제 44지선다
useReducer(booksReducer, undefined, loadInitialState)에서 세 번째 인자 loadInitialState의 역할은?
문제 54지선다
booksReducer의 default 분기에서 에러를 던지도록 작성한 이유로 가장 알맞은 것은?

참고 자료

Last updated on