Skip to Content
WebReactReact 중급04. 상세 페이지, 중첩 라우트, 로더

이번 편의 결과물: 책 제목을 누르면 /books/:bookId 상세 페이지가 loader로 데이터를 받아 렌더되고, 그 안에서 요약·메모 화면이 중첩 라우트로 전환됩니다. · 다루는 개념: 동적 파라미터, 중첩 라우트와 Outlet, loader, useLoaderData/useParams

이 편에서 만드는 파일

bookshelf/src/ ├── router.jsx (~) └── pages/ └── BookDetailPage.jsx (+)

개념 정리

동적 파라미터

라우트 경로에 :이름을 쓰면 그 자리에 오는 값을 파라미터로 받을 수 있습니다. /books/:bookId/books/1, /books/7 같은 URL 전부와 매칭되고, 1·7bookId 파라미터가 됩니다.

라우트 경로실제 URLparams
/books/:bookId/books/3{ bookId: '3' }
/books/:bookId/memo/books/3/memo{ bookId: '3' }

loader — 렌더 전에 데이터 준비

useEffect로 데이터를 가져오면 컴포넌트가 먼저 빈 화면(또는 로딩 문구)으로 렌더된 다음 데이터가 도착해야 실제 내용이 나타납니다. loader는 라우트가 화면에 그려지기 전에 실행되어, 컴포넌트는 처음부터 데이터를 가진 채로 렌더됩니다.

방식실행 시점첫 렌더 화면
useEffect로 fetch컴포넌트가 마운트된 후빈 화면 → 데이터 도착 후 내용
라우트 loader그 라우트로 이동이 확정되기 전데이터를 가진 화면

loader{ params }를 인자로 받는 함수입니다. 이 앱은 서버가 없으므로 loaderlocalStorage를 직접 읽어 bookId에 맞는 책을 찾습니다. 찾지 못하면 Responsethrow해 그 라우트의 errorElement로 넘깁니다.

useLoaderData, useParams

반환하는 값
useLoaderData()그 라우트의 loader가 반환한 값
useParams()현재 URL과 매칭된 동적 파라미터 객체

중첩 라우트와 Outlet

children 배열로 라우트를 중첩하면, 부모 라우트의 element 안에 <Outlet />을 둔 자리에 자식 라우트의 element가 렌더됩니다. index: true인 자식은 부모 경로와 정확히 같을 때(/books/3) 기본으로 렌더됩니다.

자식 라우트URL언제 렌더되는가
{ index: true, element: <BookSummaryView /> }/books/3bookId만 있고 하위 경로가 없을 때
{ path: 'memo', element: <BookMemoView /> }/books/3/memo하위 경로 memo가 붙었을 때

자식은 useOutletContext()로 부모가 <Outlet context={값} />에 넘긴 값을 읽습니다. 이 앱에서는 BookDetailPageloader로 받은 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를 입력하면 “페이지를 찾을 수 없습니다” 화면이 뜹니다.

직접 해보기

  1. BookSummaryViewbook.pages를 이용해 “읽은 쪽수 비율” 같은 파생 텍스트를 하나 추가해봅니다.
  2. bookDetailLoader가 찾은 책이 없을 때 Response 상태 코드를 404 대신 400으로 바꾸면 RouteErrorPage의 문구가 어떻게 달라지는지 확인해봅니다.

정답 보기

if (!book) { throw new Response('잘못된 요청입니다.', { status: 400 }); }

error.status404가 아니므로 RouteErrorPage는 “문제가 발생했습니다”를 보여줍니다. 상태 코드에 따라 다른 문구를 보여줄 수 있다는 뜻입니다.

자주 하는 실수

증상원인고치는 법
상세 페이지에서 useOutletContext()undefined를 반환부모의 <Outlet />context를 안 넘김<Outlet context={book} />처럼 명시적으로 넘긴다
/books/1은 되는데 /books/1/memo가 404router.jsxchildrenpath: 'memo'를 안 넣음자식 라우트 배열에 추가한다
상세 페이지 진입 시 항상 빈 화면이 잠깐 보임loader를 안 쓰고 컴포넌트 안에서 useEffect로 다시 fetch함데이터 준비를 loader로 옮긴다
params.bookId가 항상 undefined라우트 path의 파라미터 이름과 useParams()에서 꺼내는 이름이 다름두 곳의 이름(bookId)을 동일하게 맞춘다

확인 문제

문제 14지선다
라우트 경로 /books/:bookId에서 :bookId가 의미하는 것은?
문제 24지선다
loader와 useEffect로 데이터를 가져오는 방식의 차이로 옳은 것은?
문제 34지선다
부모 라우트의 element 안에 있는 Outlet의 역할은?
문제 44지선다
children 배열에서 index: true로 지정한 라우트가 렌더되는 경우는?
문제 54지선다
loader 안에서 책을 찾지 못했을 때 Response를 throw하면 일어나는 일은?

참고 자료

Last updated on