이번 편의 결과물: 책 추가·삭제가 mock 서버에 반영되고, 성공하면 목록이 자동으로 다시 조회됩니다. · 다루는 개념: useMutation, 성공/실패 핸들러, invalidateQueries, 삭제·추가 흐름
04편에서 BookListPage는 useQuery로 json-server mock 목록을 조회하는 상태까지 왔습니다. 조회만 되고 쓰기는 아직 없습니다. 이 편에서는 책 추가·삭제를 서버에 반영하고, 반영이 끝나면 목록을 자동으로 갱신합니다.
이 편에서 만드는 파일
bookshelf/src/
├── api/
│ └── books.js ~ (createBook, deleteBook 함수 추가)
├── hooks/
│ └── useBookMutations.js + (useCreateBook, useDeleteBook — 이후 편이 계속 재사용)
├── pages/
│ ├── BookListPage.jsx ~ (삭제 버튼 연결)
│ └── NewBookPage.jsx ~ (등록 폼을 서버 저장으로 전환)
└── components/
└── BookList.jsx ~ (onDelete prop 추가)개념 정리
useQuery는 읽기 전용입니다. 서버 상태를 바꾸는 요청(추가·삭제·수정)은 useMutation으로 다룹니다.
| 개념 | 역할 |
|---|---|
mutationFn | 실제 요청을 보내는 비동기 함수 |
onSuccess | 요청이 성공했을 때 실행(캐시 무효화·화면 이동 등) |
onError | 요청이 실패했을 때 실행(에러 표시) |
invalidateQueries | 지정한 queryKey를 “오래됨”으로 표시해 다음 조회 시 다시 가져오게 함 |
useMutation은 useQuery와 다르게 호출 시점에 자동으로 실행되지 않습니다. 컴포넌트가 반환한 mutate 함수를 이벤트 핸들러 안에서 직접 불러야 합니다. 성공 후 화면을 최신 상태로 만드는 표준 방법은 invalidateQueries로 관련 queryKey를 무효화하는 것입니다. TanStack Query가 알아서 다시 조회합니다.
useMutation 설정(어떤 API 함수를 부를지, 성공·실패 시 무엇을 할지)은 컴포넌트마다 새로 쓰지 않고 hooks/useBookMutations.js 한 파일에 모읍니다. 여러 컴포넌트가 같은 뮤테이션을 쓰거나, 06편처럼 낙관적 업데이트로 로직이 복잡해질 때 한 곳만 고치면 되게 하기 위해서입니다. 이 편에서 useCreateBook, useDeleteBook을 만들고, 12편에서 useUpdateBook을 추가합니다.
실습
1. API 함수에 쓰기 요청 추가
json-server는 POST /books, DELETE /books/:id를 REST 규약대로 처리합니다. api/books.js에 두 함수를 추가합니다.
// bookshelf/src/api/books.js
const API_BASE = 'http://localhost:3001'
export async function getBooks() {
const response = await fetch(`${API_BASE}/books`)
if (!response.ok) {
throw new Error(`책 목록을 가져오지 못했습니다 (상태 코드: ${response.status})`)
}
return response.json()
}
export async function createBook(book) {
const response = await fetch(`${API_BASE}/books`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(book),
})
if (!response.ok) {
throw new Error('책을 추가하지 못했습니다')
}
return response.json()
}
export async function deleteBook(id) {
const response = await fetch(`${API_BASE}/books/${id}`, { method: 'DELETE' })
if (!response.ok) {
throw new Error('책을 삭제하지 못했습니다')
}
return response.json()
}json-server는 POST 요청 본문에 id가 없으면 기존 항목 중 가장 큰 숫자 id에 1을 더해 자동으로 채웁니다.
2. 뮤테이션 훅 모음 작성
// bookshelf/src/hooks/useBookMutations.js
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createBook, deleteBook } from '../api/books.js'
export function useCreateBook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createBook,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['books'] })
},
})
}
export function useDeleteBook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteBook,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['books'] })
},
})
}컴포넌트는 useMutation을 직접 부르지 않고 이 두 훅만 가져다 씁니다. 06편에서 useDeleteBook 내부만 낙관적 업데이트로 바꿔도, 이 훅을 쓰는 컴포넌트 코드는 그대로입니다.
3. 목록 화면에 삭제 버튼 연결
// bookshelf/src/pages/BookListPage.jsx
import { Link } from 'react-router'
import { useBooks } from '../hooks/useBooks.js'
import { useDeleteBook } from '../hooks/useBookMutations.js'
import BookList from '../components/BookList.jsx'
export default function BookListPage() {
const { data: books, isLoading, isError, error } = useBooks()
const deleteMutation = useDeleteBook()
if (isLoading) {
return <p role="status">책 목록을 불러오는 중입니다...</p>
}
if (isError) {
return <p role="alert">목록을 불러오지 못했습니다: {error.message}</p>
}
return (
<section>
<header>
<h1>내 서재</h1>
<Link to="/books/new">새 책 등록</Link>
</header>
<BookList books={books} onDelete={(id) => deleteMutation.mutate(id)} />
</section>
)
}BookList가 onDelete를 받아 각 항목에 삭제 버튼을 그리도록 고칩니다.
// bookshelf/src/components/BookList.jsx
import BookCard from './BookCard.jsx'
export default function BookList({ books, onDelete }) {
if (books.length === 0) {
return <p>등록된 책이 없습니다.</p>
}
return (
<ul className="book-list">
{books.map((book) => (
<li key={book.id}>
<BookCard book={book} />
<button type="button" onClick={() => onDelete(book.id)}>
삭제
</button>
</li>
))}
</ul>
)
}4. 등록 폼을 서버 저장으로 전환
react_2의 NewBookPage는 로컬 배열에 직접 push하던 방식이었습니다. useCreateBook으로 바꿉니다.
// bookshelf/src/pages/NewBookPage.jsx
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { useCreateBook } from '../hooks/useBookMutations.js'
export default function NewBookPage() {
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [author, setAuthor] = useState('')
const [pages, setPages] = useState('')
const createMutation = useCreateBook()
function handleSubmit(event) {
event.preventDefault()
createMutation.mutate(
{
title,
author,
pages: Number(pages),
status: 'wish',
rating: 0,
coverColor: '#2563eb',
startedAt: null,
finishedAt: null,
memo: '',
},
{ onSuccess: () => navigate('/') },
)
}
return (
<form onSubmit={handleSubmit}>
<h1>책 등록</h1>
<label htmlFor="title">제목</label>
<input id="title" value={title} onChange={(e) => setTitle(e.target.value)} required />
<label htmlFor="author">저자</label>
<input id="author" value={author} onChange={(e) => setAuthor(e.target.value)} required />
<label htmlFor="pages">쪽수</label>
<input id="pages" type="number" value={pages} onChange={(e) => setPages(e.target.value)} required />
<button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? '저장 중...' : '저장'}
</button>
{createMutation.isError && <p role="alert">{createMutation.error.message}</p>}
</form>
)
}useCreateBook의 onSuccess(훅 안)는 캐시 무효화를 맡고, mutate의 두 번째 인자로 넘긴 onSuccess(호출부)는 화면 이동만 맡습니다. TanStack Query는 두 onSuccess를 모두 실행합니다.
5. 실행과 확인
npx json-server db.json --port 3001
npm run dev/화면에서 책 목록이 보인다.- “새 책 등록” 클릭 → 폼 작성 → 저장하면
/로 돌아오고 새 책이 목록에 나타난다. - 아무 책이나 “삭제” 클릭하면 목록에서 사라진다.
bookshelf/db.json파일을 열어보면 방금 추가·삭제한 내용이 실제로 저장돼 있다.
직접 해보기
addMutation에onError핸들러를 추가해 등록 실패 시에도 에러 메시지가 뜨게 만들어 보세요.- 삭제 버튼을 두 번 빠르게 누르면 어떤 일이 벌어지는지 확인하고,
deleteMutation.isPending으로 중복 클릭을 막아보세요.
정답 보기
// hooks/useBookMutations.js의 useDeleteBook은 그대로 두고, 사용하는 쪽만 변경
const deleteMutation = useDeleteBook()
// 버튼 쪽
<button
type="button"
disabled={deleteMutation.isPending}
onClick={() => onDelete(book.id)}
>
삭제
</button>isPending은 현재 진행 중인 뮤테이션이 있을 때 true가 됩니다. 어떤 항목을 삭제 중인지까지 구분하려면 deleteMutation.variables(현재 요청에 전달된 id)와 비교합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 삭제해도 목록이 그대로 | onSuccess에서 invalidateQueries 호출을 빠뜨림 | queryKey를 정확히 맞춰 무효화한다 |
| 등록 후 화면 이동이 안 됨 | navigate를 onSuccess 밖에서 호출 | 서버 응답을 받은 뒤 onSuccess 안에서 이동한다 |
콘솔에 Failed to fetch | json-server를 안 켜거나 포트가 다름 | 3001 포트로 json-server가 떠 있는지 확인한다 |