[번역] Context Inheritance in TanStack Router
TkDodo의 Context Inheritance in TanStack Router를 번역한 글입니다.

TanStack Router는 훌륭한 기능이 많아서 마음에 드는 것을 고르기 어렵습니다. 그럼에도 불구하고, 제가 그것을 실제로 보았을 때 저를 놀라게 한 것은 라우터가 중첩된 경로 사이에서 상태를 누적할 수 있게 해준다는 점입니다. 이는 실행 시간뿐만 아니라 타입 레벨에서도 가능합니다.
이 기능은 모든 부모-자식 경로 관계에 대해 타입 안전하고 완전히 유추된 방식으로 작동하지만, wp가 생각하기에 그게 작동하지 않았다면 놀랄 것 같은 가장 간단한 것부터 시작하겠습니다:
Path Params
매우 간단한 예를 보여주기 위해서, 두 개의 중첩된 경로를 살펴보겠습니다:
// nested-routes
- dashboard.$dashboardId
  - route.tsx
  - widget.$widgetId
    - index.tsxFile-Based Routes
추천대로 파일 기반 라우팅을 사용하고 있으며, 가능하다는 이유로 디렉토리 라우트와 플랫 라우트를 혼합하여 사용하고 있습니다. 이것은 개인적인 취향이며, 이 방식이 마음에 들지 않으면 TanStack Router에서 파일 기반 라우팅을 수행할 수 있는 다른 방법들이 있습니다. 예를 들어, 가상 파일 경로를 사용하여 나만의 구조를 만들 수 있습니다.
그러나 제가 보여 드리는 모든 것은 코드 기반 라우팅에서도 작동하며, 파일 기반 라우팅은 본인이 직접 작성할 코드를 생성하는 좀 더 효율적인 방법일 뿐입니다.
자식 경로(대시보드의 위젯)를 살펴보면, Route.useParams()를 호출할 때 계층 구조에서 모든 매개변수를 다시 받을 수 있음을 알 수 있습니다.
// use-params
export const Route = createFileRoute(
  '/dashboard/$dashboardId/widget/$widgetId/'
)({
  component: Widget,
})
function Widget() {
  const params = Route.useParams()
  //    ^? { dashboardId: string, widgetId: string }
}결국, 타입 안전한 라우터에서 기대할 수 있는 것이 바로 이것입니다. $dashboardId가 $widgetId 바로 옆의 경로에 있기 때문에, 이게 왜 멋진 걸까요?
$dashboardId가 숫자라는 것을 표현하고 싶다면 어떻게 할까요? 우리는 부모 경로에서 우리의 좋아하는 유효성 검사 라이브러리를 사용하여 매개변수를 파싱함으로써 그것을 정의할 것입니다.
// params-parsing
import { type } from 'arktype'
export const Route = createFileRoute('/dashboard/$dashboardId')({
  component: Dashboard,
  params: {
    parse: type({ dashboardId: 'string.integer.parse' }).assert,
  },
})Standard Schema
TanStack Router는 표준 스키마를 지원하는 모든 검증 라이브러리와 호환됩니다. 현재까지 20개 이상의 라이브러리가 해당 공통 인터페이스를 구현했으므로 이들 중에서 자유롭게 선택할 수 있습니다.
이 변화는 놀라운 결과를 가져옵니다: 이제 라우트 트리의 모든 자식 라우트가 이에 대해 알고 있습니다. 문서에서는 “경로 매개변수가 파싱된 후, 모든 자식 라우트에서 사용할 수 있다”고 간단히 설명하지만, 우리의 타입에 어떤 일이 일어나는지 보십시오:
// dashboardId-number
function Widget() {
  const params = Route.useParams()
  //    ^? { dashboardId: number, widgetId: string }
}위젯은 이제 dashboardId가 숫자라는 것을 알고 있습니다. 그렇게 간단합니다. 그것은 알고 있습니다. 🤯
잠시 생각해 보세요, 왜냐하면 이것은 몇 가지 멋진 의미를 가지고 있기 때문입니다. 부모에서 정의한 것을 자식이 경로 매개변수에 대한 타입을 알 수 있다면, 우리 라우터가 관리하는 다른 상태에도 같은 개념을 적용하는 것을 무엇이 막을까요?
아무것도 우리를 막지 않습니다. 사실, 이 상속이 작동하는 다른 몇 가지 장소가 있습니다:
Search Params
네, searchParams는 부모로부터 타입 수준에서 컨텍스트를 상속받을 수 있습니다. 애플리케이션 전체에서 사용할 수 있는 선택적 ?debug 불리언 플래그를 가지고 싶다고 가정해 봅시다. 우리가 할 일은 이를 루트 컴포넌트에서 정의하는 것입니다:
// debug-search-param
export const Route = createRootRouteWithContext<RouteContext>()({
  validateSearch: type({ debug: 'boolean=false' }).assert,
  component: Root,
})이제 모든 컴포넌트는 useSearch를 통해 그 불리언 플래그에 접근할 수 있습니다.
Tip : Search Middleware
URL에 항상
?debug=false와 같은 기본 검색 매개변수 값이 표시되는 것이 지겹다면, 라우터의 Search Middleware를 사용하여 그것에 쓰여지는 내용을 변경할 수 있습니다. 이 특정 사용 사례를 위해, 지정된 기본값과 동일한 값을 제거할 수 있는 stripSearchParams라는 내장 미들웨어가 있습니다.export const Route = createRootRouteWithContext<RouteContext>()({ validateSearch: type({ debug: 'boolean=false' }).assert, search: { middlewares: [stripSearchParams({ debug: false })], }, component: Root, })
트리에 검색 매개변수를 더 추가하면, 이들은 타입 레벨에서 병합되어 가장 정확한 결과를 생성할 것입니다. 예를 들어, 우리의 위젯 경로는 날짜 범위 필터를 추가할 수 있습니다:
// merged-search-params
export const Route = createFileRoute(
  '/dashboard/$dashboardId/widget/$widgetId/'
)({
  validateSearch: type({ 'range?': "'7d' | '30d' | '90d'" }).assert,
  component: Widget,
})
function Widget() {
  const search = Route.useSearch()
  //    ^? { debug: boolean, range?: '7d' | '30d' | '90d' }
}하지만 대시보드 경로에서 useSearch를 사용한다면, 우리는 디버그 플래그에만 접근할 수 있을 것입니다. 이 결합은 매우 강력합니다. 왜냐하면 모든 컴포넌트가 상위 경로 계층에서 사용 가능한 모든 상태에 접근할 수 있도록 보장하기 때문입니다. 필요한 것은 어떤 경로를 사용할 것인지 선언하는 것입니다.
심층 분석: 이게 어떻게 작동하나요?
솔직히, 저는 아이디어가 전혀 없습니다 😂. 라우터 저장소의 타입을 살펴보았지만, 알 수가 없었습니다. 저보다 더 똑똑한 사람의 블로그 포스트를 기다려야 이 심층 분석을 할 수 있을 것 같습니다.
React Context
상속을 할 수 있는 라우터의 또 다른 속성은 Router Context입니다. 이 컨텍스트는 createRootRouteWithContext를 사용할 경우 루트 라우트에서 생성되며, 그 초기 값은 createRouter 자체에 전달됩니다. 일반적으로 라우트 로더에 대한 의존성 주입에 사용됩니다. TanStack Query와 함께 사용될 때, 우리는 일반적으로 QueryClient를 배포하는 데 사용합니다.
// react-context
const queryClient = new QueryClient()
const router = createRouter({
  routeTree,
  context: {
    queryClient,
  },
  Wrap: ({ children }) => {
    return (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    )
  },
})그렇다면, 이 queryClient 인스턴스는 모든 로더에서 사용할 수 있습니다.
// queryClient-in-loader
export const Route = createFileRoute('/dashboard/$dashboardId')({
  loader: async ({ context, params }) => {
    //             ^? { queryClient: QueryClient }
    await context.queryClient.ensureQueryData(
      dashboardQueryOptions(params.dashboardId)
    )
  },
  component: Dashboard,
})
function Dashboard() {
  const params = Route.useParams()
  const { data } = useSuspenseQuery(
    dashboardQueryOptions(params.dashboardId)
  )
}이것은 훌륭하지만, Route Context는 단순한 의존성 주입보다 더 많은 것입니다. 다시 말해, 문서에서는 “각 경로에서 컨텍스트를 수정할 수 있으며, 수정 사항은 모든 자식 경로에서 사용할 수 있다”고 기술하고 있습니다.
Route Context
특정 경로에 대한 컨텍스트를 수정하기 위해, 우리는 beforeLoad 함수를 정의하고 원하는 것을 반환할 수 있습니다:
// beforeLoad
export const Route = createFileRoute('/dashboard/$dashboardId')({
  beforeLoad: () => ({ hello: 'world' } as const),
  loader: async ({ context, params }) => {
    //             ^? {
    //                  queryClient: QueryClient;
    //                  readonly hello: "world";
    //                }
    await context.queryClient.ensureQueryData(
      dashboardQueryOptions(params.dashboardId)
    )
  },
  component: Dashboard,
})우리가 반환하는 것은 이 경로의 로더뿐만 아니라 자식 경로에서 컨텍스트를 소비하는 곳에서도 사용할 수 있습니다. 예를 들어, useRouteContext와 함께 사용할 수 있습니다:
// useRouteContext
function Widget() {
  const { hello } = Route.useRouteContext()
  //      ^? "world"
}beforeLoad
현재로서는
beforeLoad가 Route Context를 증강할 수 있는 유일한 함수임을 유의해 주세요. 그러나 향후 더 많은 생애주기 메서드로 이 기능을 확장할 계획이 있습니다.
좋습니다, 그래서 컨텍스트는 부모로부터 값을 발전시키고 상속할 수 있지만, 그게 어디에 유용할까요? 물론 우리는 일반적인 빵부스러기 만드는 것과 같은 것에 사용할 수 있지만, 다음 블로그 포스트에서 쓸 React Query에 대한 멋진 사용 사례를 찾은 것 같습니다.