react-router-dom의 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} />;
};
이러한 초기 접근 방식은 처음에는 동작하는 것처럼 보였지만, 곧바로 아래와 같은 명확한 한계에 부딪히게 되었다.
AS-IS: 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의 책임이 분산되어 코드의 응집도가 떨어졌다.
TO-BE: 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 컴포넌트는 그 정보를 가져다 쓰기만 하는 이상적인 구조를 만들 수 있다.
1단계: 라우터에 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: '검색' },
},
],
},
],
},
]);
2단계: 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는 중첩 라우팅 구조에서 컴포넌트 간의 데이터 흐름을 매우 선언적이고 효율적으로 만들어주는 편리한 기능이었다.
세상에는 참 특이한 기능이 많다..