Skip to Content
WebReactReact 중급05. 추가 페이지와 폼 액션

이번 편의 결과물: 새 책 추가 폼이 /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}>에 그대로 연결
isPendingfn이 실행 중이면 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 dev

4. 확인

  • 목록 페이지 상단의 “책 추가” 링크를 누르면 /books/new로 이동합니다.
  • 제목·저자를 채우고 추가를 누르면 버튼이 잠깐 “추가하는 중…”으로 바뀌고, 목록 페이지(/)로 자동 이동합니다.
  • 목록에 방금 추가한 책이 보입니다(새로고침해도 남아 있습니다).
  • 제목이나 저자를 비운 채 추가를 누르면 페이지 이동 없이 빨간 에러 문구가 폼 아래에 나타납니다.

직접 해보기

  1. NewBookPage에 “취소” 링크(Link to="/")를 추가해 폼을 채우다 그만둘 수 있게 만들어 보세요.
  2. submitNewBook이 만든 newBook 객체를 saveBooks 호출 전에 console.log로 찍어, id가 기존 책들과 겹치지 않고 하나씩 늘어나는지 확인해 보세요.

풀이 보기

import { Link } from 'react-router'; // ...기존 import 유지 // </form> 바로 아래 <Link to="/">취소</Link>
console.log('생성된 책:', newBook); saveBooks([...books, newBook]);

책이 하나도 없는 상태에서 첫 추가라면 id1로 찍힙니다. 이미 책이 있다면 가장 큰 id보다 1 큰 값이 찍힙니다.

자주 하는 실수

증상원인고치는 법
제출해도 아무 반응이 없음<form>action={formAction}을 안 붙임action 속성에 useActionState가 반환한 두 번째 값을 그대로 연결
버튼이 “추가하는 중…”에서 멈춤submitNewBookreturn을 안 함(모든 코드 경로에서 상태 객체를 반환해야 함)성공·실패 각 분기 끝에 return { error: ... } 확인
새로고침하면 추가한 책이 사라짐saveBooks 호출을 빼먹음saveBooks([...books, newBook]) 호출 여부 확인
목록에 새 책이 안 보임NewBookPageBookListPageSTORAGE_KEY 문자열이 다름두 파일 모두 'bookshelf-books'로 동일한지 확인

확인 문제

문제 14지선다
useActionState가 반환하는 세 값의 순서로 올바른 것은
문제 24지선다
이번 편에서 만든 NewBookPage의 입력창이 제어 컴포넌트가 아닌 이유는 무엇인가
문제 34지선다
NewBookPage와 BookListPage에 저장 로직이 각각 따로 있는 지금 상태를 다음 편에서 정리하는 방법으로 알맞은 것은

참고 자료

Last updated on