useMatches와 handle로 똑똑하게 라우팅하기react-router-dom을 사용해 중첩 라우팅(Nested Routing)을 구성하다 보면, "자식 페이지의 정보를 부모 레이아웃에서 어떻게 알 수 있을까?"라는 흔한 문제에 부딪힌다.
예를 들어, 현재 페이지의 URL에 따라 부모인 Layout 컴포넌트가 동적으로 페이지 제목(title)을 변경해야 하는 경우이다.
처음 이 문제를 마주했을 때, 나는 라우트 관련 파일을 분리한다는 생각을 하지 못했고, 각 페이지 라우트에 개별적으로 Layout 컴포넌트를 적용해야 한다고 당연하게 생각했다.
또한, MainPage와 그 외 페이지가 서로 다른 헤더를 가져야 했기 때문에, Header 컴포넌트가 type이라는 prop을 받아 동적으로 다른 헤더를 렌더링하도록 구현했다.
// 동적으로 다른 헤더를 보여주는 Header 컴포넌트
import { MainHeader, PageHeader } from '../header';
type HeaderType = 'main' | 'page';
type Props = {
type: HeaderType;
title?: string;
};
const HEADER_COMPONENTS = {
main: MainHeader,
page: PageHeader,
} as const;
export const Header = ({ type, title }: Props) => {
const HeaderComponent = HEADER_COMPONENTS[type];
return <HeaderComponent title={title} />;
};
이러한 초기 접근 방식은 처음에는 동작하는 것처럼 보였지만, 곧바로 아래와 같은 명확한 한계에 부딪히게 되었다.
Layout에 Prop 직접 전달하기초기 접근 방식에 따라, 라우트 설정에서 Layout 컴포넌트에 직접 pageTitle과 같은 prop을 전달했다.
// router.tsx (AS-IS)
const router = createBrowserRouter([
// ...
{
path: ROUTER_PATH.COMMUNITY,
// 👇 Layout에 직접 title을 prop으로 전달
element: <Layout pageTitle='커뮤니티' />,
children: [
{
index: true,
element: <CommunityPage />,
},
],
},
// ...
]);
이 방식은 프로젝트를 진행하면서 몇 가지 명확한 한계점을 드러냈다.
router.tsx 파일까지 함께 수정해야 했다. 이는 매우 번거로운 작업이었다.Layout 컴포넌트가 어떤 title을 보여줄지에 대한 정보가 Layout 자신이 아닌, 외부의 router.tsx 파일에 흩어져 있었다. Layout의 책임이 분산되어 코드의 응집도가 떨어졌다.useMatches와 handle로 개선하기이러한 불편함을 해결하기 위해 react-router-dom v6.4부터 도입된 useMatches 훅과 handle 프로퍼티를 사용했다.
useMatches 공식 문서 정의먼저 공식 문서의 정의를 살펴보자.
react-router-dom : useMatches
function useMatches(): UIMatch[]현재 활성화된 라우트 매치 배열을 반환합니다. 부모/자식 라우트의
loaderData나 라우트의handle프로퍼티에 접근할 때 유용합니다.
여기서 UIMatch는 현재 URL과 일치하는 각 라우트 객체(경로, 파라미터, 그리고 우리가 사용할 handle 등)에 대한 정보를 담고 있는 객체이다.
handle: 라우터를 설정할 때, 각 라우트(route) 객체에 우리가 원하는 데이터를 담을 수 있는 프로퍼티이다.useMatches: 현재 URL과 일치하는(match) 모든 라우트 객체들의 정보를 UIMatch 배열로 반환하는 훅이다. 이 배열을 통해 상위 컴포넌트는 하위 컴포넌트의 handle에 접근할 수 있다.이 두 가지를 조합하면, 라우터 설정 파일이 모든 경로와 그에 따른 메타데이터(제목 등)를 관리하는 '진실의 원천(Single Source of Truth)'이 되고, Layout 컴포넌트는 그 정보를 가져다 쓰기만 하는 이상적인 구조를 만들 수 있다.
handle 정보 추가하기가장 먼저, 각 페이지 라우트에 handle 프로퍼티를 추가하여 필요한 정보(여기서는 title)를 심어준다.
// router.tsx (TO-BE)
const router = createBrowserRouter([
{
path: '/',
element: <AuthRoute />,
children: [
// ...
{
element: <Layout pageType='Page' />, // 이제 Layout에 title을 넘기지 않는다.
children: [
{
path: 'clip',
element: <ClipPage />,
// 👇 각 라우트에 handle 프로퍼티를 추가!
handle: { title: '클립 저장' },
},
{
path: 'search',
element: <SearchPage />,
handle: { title: '검색' },
},
],
},
],
},
]);
Layout 컴포넌트에서 useMatches로 정보 가져오기다음으로, 부모인 Layout 컴포넌트가 useMatches 훅을 사용해 현재 활성화된 자식 라우트의 handle 정보를 읽어오도록 수정한다.
// Layout.tsx
import { Outlet, useMatches } from 'react-router-dom';
// ...
type RouteHandle = {
title?: string;
};
export const Layout = ({ pageType }: { pageType: 'Main' | 'Page' }) => {
const matches = useMatches();
const handle = matches[matches.length - 1]?.handle as RouteHandle | undefined;
const title = handle?.title;
return (
<PageLayout>
<Header type={pageType} title={title} />
<PageContainer>
<Outlet />
</PageContainer>
<NavigateBar />
</PageLayout>
);
};
matches 배열은 최상위 라우트부터 현재 라우트까지의 모든 정보를 담고 있으며, matches[matches.length - 1]을 통해 가장 마지막에 매치된, 즉 현재 화면에 보이는 페이지의 라우트 정보에 접근할 수 있다.
useMatches와 handle을 사용하는 이 패턴은 다음과 같은 명확한 장점을 제공한다.
Layout 컴포넌트는 더 이상 하위 페이지들의 경로와 제목을 알 필요가 없다. 오직 "현재 라우트의 handle에서 title을 가져와 렌더링한다"는 책임만 가진다.router.tsx 파일 한곳에서 관리되므로, 새로운 페이지를 추가하거나 기존 페이지의 제목을 변경할 때 이 파일 하나만 수정하면 된다.이처럼 useMatches는 중첩 라우팅 구조에서 컴포넌트 간의 데이터 흐름을 매우 선언적이고 효율적으로 만들어주는 편리한 기능이었다.
세상에는 참 특이한 기능이 많다..