Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion docs/router/api/router/useMatchRouteHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ id: useMatchRouteHook
title: useMatchRoute hook
---

The `useMatchRoute` hook is a hook that returns a `matchRoute` function that can be used to match a route against either the current or pending location.
The `useMatchRoute` hook returns a `matchRoute` function that can be used to match a route against either the current or pending location. The hook subscribes the component to changes in the router state used for matching, making it useful when the match result affects what the component renders.

The `matchRoute` function's identity changes when that router state changes. For an imperative check, such as one performed in an event handler, use [`useRouter`](./useRouterHook.md) and call `router.matchRoute` instead. The router instance is stable, and this avoids subscribing the component to router state that it does not use while rendering.

## useMatchRoute returns

Expand All @@ -25,6 +27,8 @@ The `matchRoute` function accepts a single argument, an `options` object.

## Examples

Use `useMatchRoute` when the result determines the rendered output:

```tsx
import { useMatchRoute } from '@tanstack/react-router'

Expand All @@ -33,7 +37,36 @@ function Component() {
const matchRoute = useMatchRoute()
const params = matchRoute({ to: '/posts/$postId' })
// ^ { postId: '123' }

return params ? <Post postId={params.postId} /> : <NotFound />
}
```

For a check made at the time of an event, call `router.matchRoute` directly:

```tsx
import { useRouter } from '@tanstack/react-router'

function Component() {
const router = useRouter()

return (
<button
onClick={() => {
const params = router.matchRoute({ to: '/posts/$postId' })
// ^ { postId: '123' }
}}
>
Check current route
</button>
)
}
```

Additional matching examples:

```tsx
import { useMatchRoute } from '@tanstack/react-router'

// Current location: /posts/123
function Component() {
Expand Down
26 changes: 23 additions & 3 deletions docs/router/guide/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,7 @@ The `router.navigate` method is the same as the `navigate` function returned by

## `useMatchRoute` and `<MatchRoute>`

The `useMatchRoute` hook and `<MatchRoute>` component are the same thing, but the hook is a bit more flexible. They both accept the standard navigation `ToOptions` interface either as options or props and return `true/false` if that route is currently matched. It also has a handy `pending` option that will return `true` if the route is currently pending (e.g. a route is currently transitioning to that route). This can be extremely useful for showing optimistic UI around where a user is navigating:
The `useMatchRoute` hook and `<MatchRoute>` component are the same thing, but the hook is a bit more flexible. They both accept the standard navigation `ToOptions` interface, either as options or props, to determine whether a route is currently matched. The `pending` option checks whether the route is currently pending (e.g. the router is currently transitioning to that route). This can be extremely useful for showing optimistic UI around where a user is navigating:

```tsx
function Component() {
Expand Down Expand Up @@ -828,7 +828,7 @@ function Component() {
}
```

The hook version `useMatchRoute` returns a function that can be called programmatically to check if a route is matched:
The hook version `useMatchRoute` returns a function for checking whether a route is matched. It subscribes the component to the router state used for matching, so use it when the result affects rendering or to trigger an effect. This subscription ensures the component updates as the current or pending location changes:

```tsx
function Component() {
Expand All @@ -838,7 +838,7 @@ function Component() {
if (matchRoute({ to: '/users', pending: true })) {
console.info('The /users route is matched and pending')
}
})
}, [matchRoute])

return (
<div>
Expand All @@ -848,6 +848,26 @@ function Component() {
}
```

The `matchRoute` function returned by `useMatchRoute` changes identity when the relevant router state changes. If you only need to check the route at the time an event occurs, use the stable router instance returned by `useRouter` and call `router.matchRoute` directly. This reads the latest router state without subscribing the component to matching state that it does not use while rendering:

```tsx
function Component() {
const router = useRouter()

return (
<button
onClick={() => {
if (router.matchRoute({ to: '/users' }, { fuzzy: true })) {
console.info('The users route is active')
}
}}
>
Check current route
</button>
)
}
```

---

Phew! That's a lot of navigating! That said, hopefully you're feeling pretty good about getting around your application now. Let's move on!
12 changes: 12 additions & 0 deletions e2e/react-router/react-compiler/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Compiler useMatchRoute test</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
29 changes: 29 additions & 0 deletions e2e/react-router/react-compiler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "tanstack-router-e2e-react-compiler",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"dev:e2e": "vite",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"start": "vite",
"test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
Comment thread
Sheraff marked this conversation as resolved.
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@playwright/test": "^1.61.0",
"@rolldown/plugin-babel": "^0.2.0",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"vite": "^8.0.14"
}
}
25 changes: 25 additions & 0 deletions e2e/react-router/react-compiler/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const PORT = await getTestServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],
use: { baseURL },
webServer: {
command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm dev:e2e --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
73 changes: 73 additions & 0 deletions e2e/react-router/react-compiler/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
linkOptions,
useMatchRoute,
} from '@tanstack/react-router'

const links = linkOptions([
{ to: '/home', label: 'Home' },
{ to: '/about', label: 'About' },
])

function useRouteName() {
const matchRoute = useMatchRoute()

return links.find((link) => matchRoute(link))?.label ?? 'Unknown'
}

function RootComponent() {
const matchedRoute = useRouteName()

return (
<>
<nav>
{links.map((link) => (
<Link key={link.label} {...link}>
{link.label}
</Link>
))}
</nav>
<p>
Matched route: <span data-testid="matched-route">{matchedRoute}</span>
</p>
<Outlet />
</>
)
}

const rootRoute = createRootRoute({ component: RootComponent })
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/home',
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
})
const router = createRouter({
routeTree: rootRoute.addChildren([homeRoute, aboutRoute]),
})

declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

const rootElement = document.getElementById('app')
if (!rootElement) {
throw new Error('Root element not found')
}

createRoot(rootElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
16 changes: 16 additions & 0 deletions e2e/react-router/react-compiler/tests/use-match-route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test'

test('useMatchRoute updates after navigation with React Compiler', async ({
page,
}) => {
await page.goto('/home')
await expect(page.getByTestId('matched-route')).toHaveText('Home')

await page.getByRole('link', { name: 'About' }).click()
await expect(page).toHaveURL(/\/about$/)
await expect(page.getByTestId('matched-route')).toHaveText('About')

await page.getByRole('link', { name: 'Home' }).click()
await expect(page).toHaveURL(/\/home$/)
await expect(page.getByTestId('matched-route')).toHaveText('Home')
})
15 changes: 15 additions & 0 deletions e2e/react-router/react-compiler/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"target": "ESNext",
"moduleResolution": "Bundler",
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"exclude": ["node_modules", "dist"]
}
7 changes: 7 additions & 0 deletions e2e/react-router/react-compiler/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'

export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
})
54 changes: 34 additions & 20 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,34 +146,40 @@ export type UseMatchRouteOptions<
* `search`, etc.) and returns either `false` (no match) or the matched params
* object when the route matches the current or pending location.
*
* Useful for conditional rendering and active UI states.
* Useful for conditional rendering and active UI states because it subscribes
* the component to the router state used for matching. The returned function's
* identity changes when that state changes. For imperative checks in event
* handlers, get the router with `useRouter` and call `router.matchRoute(...)`
* to avoid that subscription.
*
* @returns A `matchRoute(options)` function that returns `false` or params.
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/useMatchRouteHook
*/
export function useMatchRoute<TRouter extends AnyRouter = RegisteredRouter>() {
export function useMatchRoute<TRouter extends AnyRouter = RegisteredRouter>(): <
const TFrom extends string = string,
const TTo extends string | undefined = undefined,
const TMaskFrom extends string = TFrom,
const TMaskTo extends string = '',
>(
opts: UseMatchRouteOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
) => false | Expand<ResolveRoute<TRouter, TFrom, TTo>['types']['allParams']> {
const router = useRouter()
if (isServer ?? router.isServer) {
return (opts) => {
const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts

if (!(isServer ?? router.isServer)) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useStore(router.stores.location, (location) => location.href)
// eslint-disable-next-line react-hooks/rules-of-hooks
useStore(router.stores.resolvedLocation, (location) => location?.href)
// eslint-disable-next-line react-hooks/rules-of-hooks
useStore(router.stores.status, (status) => status)
return router.matchRoute(rest as any, {
pending,
caseSensitive,
fuzzy,
includeSearch,
})
}
}

// eslint-disable-next-line react-hooks/rules-of-hooks
return React.useCallback(
<
const TFrom extends string = string,
const TTo extends string | undefined = undefined,
const TMaskFrom extends string = TFrom,
const TMaskTo extends string = '',
>(
opts: UseMatchRouteOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
):
| false
| Expand<ResolveRoute<TRouter, TFrom, TTo>['types']['allParams']> => {
(opts) => {
const { pending, caseSensitive, fuzzy, includeSearch, ...rest } = opts

return router.matchRoute(rest as any, {
Expand All @@ -183,7 +189,15 @@ export function useMatchRoute<TRouter extends AnyRouter = RegisteredRouter>() {
includeSearch,
})
},
[router],
[
router,
// eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps
useStore(router.stores.location, (location) => location.href),
// eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps
useStore(router.stores.resolvedLocation, (location) => location?.href),
// eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps
useStore(router.stores.status, (status) => status),
],
)
}

Expand Down
Loading
Loading