이번 편의 결과물: 책 제목을 누르면 /books/:bookId 상세 페이지가 loader로 데이터를 받아 렌더되고, 그 안에서 요약·메모 화면이 중첩 라우트로 전환됩니다. · 다루는 개념: 동적 파라미터, 중첩 라우트와 Outlet, loader, useLoaderData/useParams
이 편에서 만드는 파일
bookshelf/src/
├── router.jsx (~)
└── pages/
└── BookDetailPage.jsx (+)개념 정리
동적 파라미터
라우트 경로에 :이름을 쓰면 그 자리에 오는 값을 파라미터로 받을 수 있습니다. /books/:bookId는 /books/1, /books/7 같은 URL 전부와 매칭되고, 1·7이 bookId 파라미터가 됩니다.
| 라우트 경로 | 실제 URL | params |
|---|---|---|
/books/:bookId | /books/3 | { bookId: '3' } |
/books/:bookId/memo | /books/3/memo | { bookId: '3' } |
loader — 렌더 전에 데이터 준비
useEffect로 데이터를 가져오면 컴포넌트가 먼저 빈 화면(또는 로딩 문구)으로 렌더된 다음 데이터가 도착해야 실제 내용이 나타납니다. loader는 라우트가 화면에 그려지기 전에 실행되어, 컴포넌트는 처음부터 데이터를 가진 채로 렌더됩니다.
| 방식 | 실행 시점 | 첫 렌더 화면 |
|---|---|---|
useEffect로 fetch | 컴포넌트가 마운트된 후 | 빈 화면 → 데이터 도착 후 내용 |
라우트 loader | 그 라우트로 이동이 확정되기 전 | 데이터를 가진 화면 |
loader는 { params }를 인자로 받는 함수입니다. 이 앱은 서버가 없으므로 loader가 localStorage를 직접 읽어 bookId에 맞는 책을 찾습니다. 찾지 못하면 Response를 throw해 그 라우트의 errorElement로 넘깁니다.
useLoaderData, useParams
| 훅 | 반환하는 값 |
|---|---|
useLoaderData() | 그 라우트의 loader가 반환한 값 |
useParams() | 현재 URL과 매칭된 동적 파라미터 객체 |
중첩 라우트와 Outlet
children 배열로 라우트를 중첩하면, 부모 라우트의 element 안에 <Outlet />을 둔 자리에 자식 라우트의 element가 렌더됩니다. index: true인 자식은 부모 경로와 정확히 같을 때(/books/3) 기본으로 렌더됩니다.
| 자식 라우트 | URL | 언제 렌더되는가 |
|---|---|---|
{ index: true, element: <BookSummaryView /> } | /books/3 | bookId만 있고 하위 경로가 없을 때 |
{ path: 'memo', element: <BookMemoView /> } | /books/3/memo | 하위 경로 memo가 붙었을 때 |
자식은 useOutletContext()로 부모가 <Outlet context={값} />에 넘긴 값을 읽습니다. 이 앱에서는 BookDetailPage가 loader로 받은 book을 그대로 자식에게 넘겨, 자식이 다시 데이터를 찾지 않아도 되게 합니다.
실습
1. src/pages/BookDetailPage.jsx 만들기
// src/pages/BookDetailPage.jsx
import { Link, NavLink, Outlet, useLoaderData, useParams } from 'react-router';
const STORAGE_KEY = 'bookshelf-books';
export async function bookDetailLoader({ params }) {
const raw = localStorage.getItem(STORAGE_KEY);
const books = raw ? JSON.parse(raw) : [];
const book = books.find((item) => String(item.id) === params.bookId);
if (!book) {
throw new Response('해당 책을 찾을 수 없습니다.', { status: 404 });
}
return book;
}
const STATUS_LABEL = {
wish: '읽고 싶음',
reading: '읽는 중',
done: '완독',
};
export default function BookDetailPage() {
const book = useLoaderData();
const { bookId } = useParams();
return (
<div className="book-detail">
<Link to="/">목록으로</Link>
<h1 style={{ borderLeftColor: book.coverColor }}>{book.title}</h1>
<p>{book.author}</p>
<p>상태: {STATUS_LABEL[book.status]}</p>
<p>페이지: {book.pages}쪽</p>
<nav className="book-detail__nav">
<NavLink to={`/books/${bookId}`} end>
요약
</NavLink>
<NavLink to={`/books/${bookId}/memo`}>메모 전체 보기</NavLink>
</nav>
<Outlet context={book} />
</div>
);
}bookDetailLoader는 컴포넌트 밖에서 실행되는 일반 함수입니다. React 훅을 쓸 수 없고, params만으로 필요한 데이터를 직접 찾아야 합니다.
2. src/router.jsx 수정 — 상세 라우트와 중첩 자식 추가
// src/router.jsx
import {
createBrowserRouter,
Link,
useOutletContext,
useRouteError,
} from 'react-router';
import BookListPage from './pages/BookListPage.jsx';
import BookDetailPage, { bookDetailLoader } from './pages/BookDetailPage.jsx';
function RouteErrorPage() {
const error = useRouteError();
const isNotFound = error?.status === 404;
return (
<div className="route-error">
<h1>{isNotFound ? '페이지를 찾을 수 없습니다' : '문제가 발생했습니다'}</h1>
<p>{error?.statusText ?? error?.message ?? error?.data ?? '잠시 후 다시 시도해 주세요.'}</p>
<Link to="/">목록으로 돌아가기</Link>
</div>
);
}
function BookSummaryView() {
const book = useOutletContext();
return (
<section>
<h2>요약</h2>
<p>시작: {book.startedAt ?? '기록 없음'}</p>
<p>완료: {book.finishedAt ?? '진행 중'}</p>
<p>별점: {book.rating}/5</p>
</section>
);
}
function BookMemoView() {
const book = useOutletContext();
return (
<section>
<h2>메모</h2>
<p>{book.memo || '작성된 메모가 없습니다.'}</p>
</section>
);
}
export const router = createBrowserRouter([
{
path: '/',
element: <BookListPage />,
errorElement: <RouteErrorPage />,
},
{
path: '/books/:bookId',
element: <BookDetailPage />,
loader: bookDetailLoader,
errorElement: <RouteErrorPage />,
children: [
{ index: true, element: <BookSummaryView /> },
{ path: 'memo', element: <BookMemoView /> },
],
},
]);3. 실행
npm run dev확인
- 목록에서 책 제목을 클릭하면 주소창이
/books/1로 바뀌고 상세 정보(저자·상태·페이지 수)가 즉시 보입니다. 빈 화면이 잠깐 보이지 않습니다. - 상세 페이지 진입 직후 “요약” 탭 내용(시작일·완료일·별점)이 기본으로 보입니다.
- “메모 전체 보기”를 클릭하면 주소창이
/books/1/memo로 바뀌고 메모 내용이 보입니다. 페이지 상단(제목·저자·nav)은 그대로 남아 있습니다. - 목록에 없는 id로 직접
/books/999를 입력하면 “페이지를 찾을 수 없습니다” 화면이 뜹니다.
직접 해보기
BookSummaryView에book.pages를 이용해 “읽은 쪽수 비율” 같은 파생 텍스트를 하나 추가해봅니다.bookDetailLoader가 찾은 책이 없을 때Response상태 코드를404대신400으로 바꾸면RouteErrorPage의 문구가 어떻게 달라지는지 확인해봅니다.
정답 보기
if (!book) {
throw new Response('잘못된 요청입니다.', { status: 400 });
}error.status가 404가 아니므로 RouteErrorPage는 “문제가 발생했습니다”를 보여줍니다. 상태 코드에 따라 다른 문구를 보여줄 수 있다는 뜻입니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
상세 페이지에서 useOutletContext()가 undefined를 반환 | 부모의 <Outlet />에 context를 안 넘김 | <Outlet context={book} />처럼 명시적으로 넘긴다 |
/books/1은 되는데 /books/1/memo가 404 | router.jsx의 children에 path: 'memo'를 안 넣음 | 자식 라우트 배열에 추가한다 |
| 상세 페이지 진입 시 항상 빈 화면이 잠깐 보임 | loader를 안 쓰고 컴포넌트 안에서 useEffect로 다시 fetch함 | 데이터 준비를 loader로 옮긴다 |
params.bookId가 항상 undefined | 라우트 path의 파라미터 이름과 useParams()에서 꺼내는 이름이 다름 | 두 곳의 이름(bookId)을 동일하게 맞춘다 |