Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/router/api/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ title: Router API
- [`useNavigate`](./router/useNavigateHook.md)
- [`useParentMatches`](./router/useParentMatchesHook.md)
- [`useParams`](./router/useParamsHook.md)
- [`usePendingMatches`](./router/usePendingMatchesHook.md)
- [`useRouteContext`](./router/useRouteContextHook.md)
- [`useRouter`](./router/useRouterHook.md)
- [`useRouterState`](./router/useRouterStateHook.md)
Expand Down
52 changes: 52 additions & 0 deletions docs/router/api/router/usePendingMatchesHook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
id: usePendingMatchesHook
title: usePendingMatches hook
---

The `usePendingMatches` hook returns the [`RouteMatch`](./RouteMatchType.md) objects for the location the router is currently navigating to. While a navigation is in flight — running `beforeLoad`, loading code-split chunks, or awaiting loaders — these are the matches for the destination location. Once the navigation resolves (or when no navigation is in flight), the array is empty and the resolved matches are available via [`useMatches`](./useMatchesHook.md).

This is useful for optimistic UI during navigation, e.g. highlighting the navigation item of the destination route from its `staticData` before its chunks and loaders have finished.

> [!TIP]
> If you want the currently rendered matches, use [`useMatches`](./useMatchesHook.md) instead.

## usePendingMatches options

The `usePendingMatches` hook accepts a single _optional_ argument, an `options` object.

### `opts.select` option

- Optional
- `(matches: RouteMatch[]) => TSelected`
- If supplied, this function will be called with the pending route matches and the return value will be returned from `usePendingMatches`. This value will also be used to determine if the hook should re-render its parent component using shallow equality checks.

### `opts.structuralSharing` option

- Type: `boolean`
- Optional
- Only supported by `@tanstack/react-router`.
- Configures whether structural sharing is enabled for the value returned by `select`.
- See the [Render Optimizations guide](../../guide/render-optimizations.md) for more information.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## usePendingMatches returns

- If a `select` function is provided, the return value of the `select` function.
- If no `select` function is provided, an array of [`RouteMatch`](./RouteMatchType.md) objects. The array is empty when no navigation is in flight.

## Examples

```tsx
import { useMatches, usePendingMatches } from '@tanstack/react-router'

function ActiveTab() {
const pendingTab = usePendingMatches({
select: (matches) => matches.findLast((m) => m.staticData.tab)?.staticData.tab,
})
const resolvedTab = useMatches({
select: (matches) => matches.findLast((m) => m.staticData.tab)?.staticData.tab,
})

const activeTab = pendingTab ?? resolvedTab
// ...
}
```
42 changes: 42 additions & 0 deletions packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,48 @@ export function useMatches<
) as UseMatchesResult<TRouter, TSelected>
}

/**
* Read the array of pending route matches or select a derived subset.
*
* Pending matches are populated while the router is loading the next
* location and are cleared once the navigation resolves, so the array is
* empty outside of an active navigation.
*
* Useful for optimistic UI during navigation, e.g. highlighting the target
* navigation item from the pending matches' `staticData` before the
* transition commits.
*
* @returns The array of pending matches (or the selected value).
* @link https://tanstack.com/router/latest/docs/framework/react/api/router/usePendingMatchesHook
*/
export function usePendingMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
TStructuralSharing extends boolean = boolean,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected, TStructuralSharing> &
StructuralSharingOption<TRouter, TSelected, TStructuralSharing>,
): UseMatchesResult<TRouter, TSelected> {
const router = useRouter<TRouter>()

if (isServer ?? router.isServer) {
const matches = router.stores.pendingMatches.get() as Array<
MakeRouteMatchUnion<TRouter>
>
return (opts?.select ? opts.select(matches) : matches) as UseMatchesResult<
TRouter,
TSelected
>
}

// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static
return useStore(
router.stores.pendingMatches,
// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static
useStructuralSharing(opts, router),
) as UseMatchesResult<TRouter, TSelected>
}

/**
* Read the full array of active route matches or select a derived subset.
*
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export {
useMatchRoute,
MatchRoute,
useMatches,
usePendingMatches,
useParentMatches,
useChildMatches,
} from './Matches'
Expand Down
71 changes: 70 additions & 1 deletion packages/react-router/tests/Matches.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { afterEach, describe, expect, test } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { createMemoryHistory } from '@tanstack/history'
import {
Link,
Expand All @@ -11,6 +18,7 @@ import {
isMatch,
useMatchRoute,
useMatches,
usePendingMatches,
} from '../src'

const rootRoute = createRootRoute()
Expand Down Expand Up @@ -354,3 +362,64 @@ describe('matching on different param types', () => {
},
)
})

describe('usePendingMatches', () => {
afterEach(() => cleanup())

test('exposes the destination matches while a navigation is loading', async () => {
let resolveLoader!: () => void
const loaderPromise = new Promise<void>((resolve) => {
resolveLoader = resolve
})

const RootComponent = () => {
const pendingPath = usePendingMatches({
select: (matches) => matches[matches.length - 1]?.pathname ?? '',
})
return (
<>
<div data-testid="pending-path">{pendingPath}</div>
<Outlet />
</>
)
}

const root = createRootRoute({
component: RootComponent,
})

const home = createRoute({
getParentRoute: () => root,
path: '/',
component: () => <Link to="/posts">To Posts</Link>,
})

const posts = createRoute({
getParentRoute: () => root,
path: 'posts',
loader: () => loaderPromise,
component: () => <div>Posts</div>,
})

const router = createRouter({
routeTree: root.addChildren([home, posts]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(<RouterProvider router={router} />)

fireEvent.click(await screen.findByText('To Posts'))

await waitFor(() =>
expect(screen.getByTestId('pending-path').textContent).toBe('/posts'),
)

await act(async () => {
resolveLoader()
await loaderPromise
})

await screen.findByText('Posts')
expect(screen.getByTestId('pending-path').textContent).toBe('')
})
})
17 changes: 17 additions & 0 deletions packages/solid-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,23 @@ export function useMatches<
}) as Solid.Accessor<UseMatchesResult<TRouter, TSelected>>
}

export function usePendingMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected>,
): Solid.Accessor<UseMatchesResult<TRouter, TSelected>> {
const router = useRouter<TRouter>()
return Solid.createMemo((prev: TSelected | undefined) => {
const matches = router.stores.pendingMatches.get() as Array<
MakeRouteMatchUnion<TRouter>
>
const res = opts?.select ? opts.select(matches) : matches
if (prev === undefined) return res
return replaceEqualDeep(prev, res) as any
Comment on lines +233 to +234

@coderabbitai coderabbitai Bot Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add curly braces and improve type safety.

As per coding guidelines, control statements like if must use curly braces. Additionally, replace the any cast with UseMatchesResult for strict type safety.

🐛 Proposed fix
-    if (prev === undefined) return res
-    return replaceEqualDeep(prev, res) as any
+    if (prev === undefined) {
+      return res
+    }
+    return replaceEqualDeep(prev, res) as UseMatchesResult<TRouter, TSelected>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (prev === undefined) return res
return replaceEqualDeep(prev, res) as any
if (prev === undefined) {
return res
}
return replaceEqualDeep(prev, res) as UseMatchesResult<TRouter, TSelected>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/solid-router/src/Matches.tsx` around lines 233 - 234, Update the
conditional return logic in Matches.tsx to wrap the prev === undefined branch in
curly braces, and replace the any cast on replaceEqualDeep with UseMatchesResult
to preserve strict typing.

Source: Coding guidelines

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typed cast doesn't compile here: createMemo then infers the callback's return as TSelected | UseMatchesResult<...>, which conflicts with the prev: TSelected | undefined parameter (TS2345). The as any is what collapses that inference conflict — it's the same reason useMatches directly above is written this way, and this hook deliberately mirrors it. Happy to change both in a follow-up if maintainers prefer another shape.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thomastvedt, agreed — the typed-cast recommendation is incorrect here. Since usePendingMatches intentionally mirrors useMatches, retaining as any is appropriate to avoid the createMemo generic inference conflict you described.

The only remaining part of the finding is the project’s curly-brace style requirement; no type-cast change is needed in this PR.


✏️ Learnings added
Learnt from: thomastvedt
Repo: TanStack/router PR: 7863
File: packages/solid-router/src/Matches.tsx:233-234
Timestamp: 2026-07-20T20:01:01.454Z
Learning: In `packages/solid-router/src/Matches.tsx`, both `useMatches` and `usePendingMatches` use `as any` on the `replaceEqualDeep` result because casting to `UseMatchesResult<TRouter, TSelected>` causes a `Solid.createMemo` generic inference conflict (TS2345): the callback return is inferred as `TSelected | UseMatchesResult<TRouter, TSelected>`, incompatible with its `prev: TSelected | undefined` parameter.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}) as Solid.Accessor<UseMatchesResult<TRouter, TSelected>>
}

export function useParentMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
Expand Down
1 change: 1 addition & 0 deletions packages/solid-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export {
useMatchRoute,
MatchRoute,
useMatches,
usePendingMatches,
useParentMatches,
useChildMatches,
} from './Matches'
Expand Down
59 changes: 59 additions & 0 deletions packages/solid-router/tests/Matches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isMatch,
useMatchRoute,
useMatches,
usePendingMatches,
} from '../src'

const rootRoute = createRootRoute()
Expand Down Expand Up @@ -443,3 +444,61 @@ describe('matching on different param types', () => {
},
)
})

describe('usePendingMatches', () => {
afterEach(() => cleanup())

test('exposes the destination matches while a navigation is loading', async () => {
let resolveLoader!: () => void
const loaderPromise = new Promise<void>((resolve) => {
resolveLoader = resolve
})

const root = createRootRoute({
component: () => {
const pendingPath = usePendingMatches({
select: (matches) => matches[matches.length - 1]?.pathname ?? '',
})
return (
<>
<div data-testid="pending-path">{pendingPath()}</div>
<Outlet />
</>
)
},
})

const home = createRoute({
getParentRoute: () => root,
path: '/',
component: () => <Link to="/posts">To Posts</Link>,
})

const posts = createRoute({
getParentRoute: () => root,
path: 'posts',
loader: () => loaderPromise,
component: () => <div>Posts</div>,
})

const router = createRouter({
routeTree: root.addChildren([home, posts]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

render(() => <RouterProvider router={router} />)

fireEvent.click(await screen.findByText('To Posts'))

await waitFor(() =>
expect(screen.getByTestId('pending-path').textContent).toBe('/posts'),
)

resolveLoader()

await screen.findByText('Posts')
await waitFor(() =>
expect(screen.getByTestId('pending-path').textContent).toBe(''),
)
})
})
14 changes: 14 additions & 0 deletions packages/vue-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,20 @@ export function useMatches<
})
}

export function usePendingMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
>(
opts?: UseMatchesBaseOptions<TRouter, TSelected>,
): Vue.Ref<UseMatchesResult<TRouter, TSelected>> {
const router = useRouter<TRouter>()
return useStore(router.stores.pendingMatches, (matches) => {
return opts?.select
? opts.select(matches as Array<MakeRouteMatchUnion<TRouter>>)
: (matches as any)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function useParentMatches<
TRouter extends AnyRouter = RegisteredRouter,
TSelected = unknown,
Expand Down
1 change: 1 addition & 0 deletions packages/vue-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ export {
useMatchRoute,
MatchRoute,
useMatches,
usePendingMatches,
useParentMatches,
useChildMatches,
} from './Matches'
Expand Down
Loading