이번 편의 결과물: 새 책 추가 폼이 /books/new라는 별도 페이지에서 동작하고, 제출 중·실패 상태가 화면에 표시됩니다. 다루는 개념: 새 라우트 추가, useNavigate로 제출 후 이동, React 19 useActionState로 제출 상태(pending·에러) 관리
이 편에서 만드는 파일
bookshelf/src/
├── router.jsx ~ /books/new 라우트 추가
└── pages/
├── NewBookPage.jsx + 신규
└── BookListPage.jsx ~ 인라인 BookForm 제거, "책 추가" 링크로 교체개념 정리
지금까지 책 추가 폼(BookForm)은 목록 페이지 안에 그대로 있었습니다. 이번 편은 그 폼을 /books/new라는 별도 라우트로 옮기고, 제출 방식도 바꿉니다.
React Router의 라우트 객체에는 <Form> 제출을 가로채는 action 필드가 있지만, 이 폼은 페이지 하나 안에서 완결되고 다른 라우트의 로더를 다시 실행할 필요가 없으므로 라우트 action 대신 React 19의 useActionState를 컴포넌트 안에서 직접 씁니다.
| 요소 | 역할 |
|---|---|
useActionState(fn, initialState) | 폼 제출 함수 fn을 감싸 [state, formAction, isPending]을 반환 |
fn(prevState, formData) | 폼이 제출될 때 실행. 이전 상태와 제출된 FormData를 받음 |
formAction | <form action={formAction}>에 그대로 연결 |
isPending | fn이 실행 중이면 true. 버튼 비활성화·로딩 문구에 사용 |
useActionState로 만든 폼은 비제어 컴포넌트입니다. value·onChange를 각 입력마다 연결하지 않고, 제출 시점에 FormData에서 값을 한 번에 꺼냅니다. react_1에서 쓰던 useState 여러 개 대신 formData.get('title') 같은 방식으로 값을 읽습니다. 입력 개수가 늘어나도 상태 선언이 늘지 않는 점이 장점입니다.
제출이 실패해도 브라우저는 입력창의 DOM 값을 그대로 유지합니다. React가 value를 강제하지 않기 때문입니다. 그래서 검증 실패 후 사용자가 입력을 다시 치지 않아도 됩니다(08편에서 에러 표시와 함께 확인합니다).
이번 편의 저장 로직(localStorage 읽기·쓰기)은 BookListPage.jsx와 마찬가지로 NewBookPage.jsx 안에 그대로 둡니다. 같은 코드가 두 파일에 반복되는 것은 06편에서 useLocalStorage 훅으로 정리합니다.
React 19에는 이 밖에도 use()로 Context를 조건부로 읽는 기능과, 서버 왕복이 있는 폼에서 화면을 먼저 갱신하는 useOptimistic이 있습니다. bookshelf는 로컬 저장소만 쓰는 클라이언트 앱이라 이 두 기능은 이번 과목에서 실습으로 확장하지 않습니다.
실습
1. 파일 만들기
src/pages/NewBookPage.jsx를 새로 만듭니다.
2. 코드 작성
// src/pages/NewBookPage.jsx
import { useActionState } from 'react';
import { useNavigate } from 'react-router';
const STORAGE_KEY = 'bookshelf-books';
const COVER_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2'];
const initialState = { error: null };
function loadBooks() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveBooks(books) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(books));
}
export default function NewBookPage() {
const navigate = useNavigate();
async function submitNewBook(prevState, formData) {
const title = formData.get('title').trim();
const author = formData.get('author').trim();
if (title === '' || author === '') {
return { error: '제목과 저자는 반드시 입력합니다.' };
}
const books = loadBooks();
const nextId = books.length === 0 ? 1 : Math.max(...books.map((book) => book.id)) + 1;
const pagesInput = Number(formData.get('pages'));
const newBook = {
id: nextId,
title,
author,
status: formData.get('status'),
rating: 0,
pages: Number.isNaN(pagesInput) ? 0 : pagesInput,
coverColor: COVER_COLORS[Math.floor(Math.random() * COVER_COLORS.length)],
startedAt: null,
finishedAt: null,
memo: formData.get('memo').trim(),
};
saveBooks([...books, newBook]);
navigate('/');
return { error: null };
}
const [state, formAction, isPending] = useActionState(submitNewBook, initialState);
return (
<div className="page">
<h1>책 추가</h1>
<form action={formAction}>
<label htmlFor="new-book-title">제목</label>
<input id="new-book-title" name="title" type="text" placeholder="예: 클린 코드" />
<label htmlFor="new-book-author">저자</label>
<input id="new-book-author" name="author" type="text" placeholder="예: 로버트 씨 마틴" />
<label htmlFor="new-book-pages">쪽수</label>
<input id="new-book-pages" name="pages" type="number" min="0" placeholder="예: 584" />
<label htmlFor="new-book-status">상태</label>
<select id="new-book-status" name="status" defaultValue="wish">
<option value="wish">읽고 싶어요</option>
<option value="reading">읽는 중</option>
<option value="done">다 읽음</option>
</select>
<label htmlFor="new-book-memo">메모</label>
<textarea id="new-book-memo" name="memo" placeholder="선택 입력" />
{state.error && (
<p className="form-error" role="alert">
{state.error}
</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '추가하는 중...' : '추가'}
</button>
</form>
</div>
);
}라우트를 등록합니다. router.jsx의 라우트 배열은 04편에서 이미 /, /books/:bookId를 가지고 있습니다. 형제 라우트를 하나 더합니다.
// src/router.jsx (일부)
import NewBookPage from './pages/NewBookPage.jsx';
// ...기존 import 유지
export const router = createBrowserRouter([
// ...기존 '/' 라우트 유지
{
path: '/books/new',
element: <NewBookPage />,
errorElement: <RouteErrorPage />,
},
// ...기존 '/books/:bookId' 라우트 유지
]);목록 페이지의 인라인 BookForm을 지우고 새 페이지로 가는 링크로 바꿉니다.
// src/pages/BookListPage.jsx (일부)
import { Link } from 'react-router';
import { useReducer, useState, useEffect } from 'react';
import { useTheme } from '../context/ThemeContext.jsx';
import { booksReducer } from '../reducers/booksReducer.js';
import BookList from '../components/BookList.jsx';
import ThemeToggle from '../components/ThemeToggle.jsx';
// BookForm import는 지웁니다. 더 이상 이 페이지에서 쓰지 않습니다.
// ...기존 STORAGE_KEY, loadInitialBooks 유지
export default function BookListPage() {
// ...기존 useReducer, useState, useEffect 유지
return (
<div className={`page page--${theme}`}>
<header className="page-header">
<h1>내 책장</h1>
<ThemeToggle />
</header>
<Link to="/books/new" className="add-book-link">
책 추가
</Link>
{/* ...기존 filter-bar, BookList 유지 */}
</div>
);
}3. 실행
npm run dev4. 확인
- 목록 페이지 상단의 “책 추가” 링크를 누르면
/books/new로 이동합니다. - 제목·저자를 채우고 추가를 누르면 버튼이 잠깐 “추가하는 중…”으로 바뀌고, 목록 페이지(
/)로 자동 이동합니다. - 목록에 방금 추가한 책이 보입니다(새로고침해도 남아 있습니다).
- 제목이나 저자를 비운 채 추가를 누르면 페이지 이동 없이 빨간 에러 문구가 폼 아래에 나타납니다.
직접 해보기
NewBookPage에 “취소” 링크(Link to="/")를 추가해 폼을 채우다 그만둘 수 있게 만들어 보세요.submitNewBook이 만든newBook객체를saveBooks호출 전에console.log로 찍어,id가 기존 책들과 겹치지 않고 하나씩 늘어나는지 확인해 보세요.
풀이 보기
import { Link } from 'react-router';
// ...기존 import 유지
// </form> 바로 아래
<Link to="/">취소</Link>console.log('생성된 책:', newBook);
saveBooks([...books, newBook]);책이 하나도 없는 상태에서 첫 추가라면 id가 1로 찍힙니다. 이미 책이 있다면 가장 큰 id보다 1 큰 값이 찍힙니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
| 제출해도 아무 반응이 없음 | <form>에 action={formAction}을 안 붙임 | action 속성에 useActionState가 반환한 두 번째 값을 그대로 연결 |
| 버튼이 “추가하는 중…”에서 멈춤 | submitNewBook이 return을 안 함(모든 코드 경로에서 상태 객체를 반환해야 함) | 성공·실패 각 분기 끝에 return { error: ... } 확인 |
| 새로고침하면 추가한 책이 사라짐 | saveBooks 호출을 빼먹음 | saveBooks([...books, newBook]) 호출 여부 확인 |
| 목록에 새 책이 안 보임 | NewBookPage와 BookListPage의 STORAGE_KEY 문자열이 다름 | 두 파일 모두 'bookshelf-books'로 동일한지 확인 |