From e8319a83cb139ca5e5c1529ffd00ed52a0126363 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Oct 2025 13:57:05 -0400 Subject: [PATCH 01/10] typegen: register lookup from route ID to route module --- packages/react-router-dev/typegen/generate.ts | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/react-router-dev/typegen/generate.ts b/packages/react-router-dev/typegen/generate.ts index 74df004ee6..dcab03fd27 100644 --- a/packages/react-router-dev/typegen/generate.ts +++ b/packages/react-router-dev/typegen/generate.ts @@ -105,13 +105,16 @@ export function generateRoutes(ctx: Context): Array { interface Register { pages: Pages routeFiles: RouteFiles + routeModules: RouteModules } } ` + "\n\n" + Babel.generate(pagesType(allPages)).code + "\n\n" + - Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code, + Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code + + "\n\n" + + Babel.generate(routeModulesType(ctx)).code, }; // **/+types/*.ts @@ -193,6 +196,29 @@ function routeFilesType({ ); } +function routeModulesType(ctx: Context) { + return t.tsTypeAliasDeclaration( + t.identifier("RouteModules"), + null, + t.tsTypeLiteral( + Object.values(ctx.config.routes).map((route) => + t.tsPropertySignature( + t.stringLiteral(route.id), + t.tsTypeAnnotation( + t.tsTypeQuery( + t.tsImportType( + t.stringLiteral( + `./${Path.relative(ctx.rootDirectory, ctx.config.appDirectory)}/${route.file}`, + ), + ), + ), + ), + ), + ), + ), + ); +} + function isInAppDirectory(ctx: Context, routeFile: string): boolean { const path = Path.resolve(ctx.config.appDirectory, routeFile); return path.startsWith(ctx.config.appDirectory); From e63a25cf0fe2919b5648073a26b4939a9f332ab2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Oct 2025 14:36:42 -0400 Subject: [PATCH 02/10] useRoute: type-safe data access for other routes on the page --- packages/react-router/index.ts | 1 + packages/react-router/lib/hooks.tsx | 23 ++++++++++++++++++++- packages/react-router/lib/types/register.ts | 10 +++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index ed1418c11e..e8e76914ec 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -144,6 +144,7 @@ export { useRouteError, useRouteLoaderData, useRoutes, + useRoute, } from "./lib/hooks"; // Expose old RR DOM API diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index d3f969ef02..330f84ce13 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -50,8 +50,13 @@ import { resolveTo, stripBasename, } from "./router/utils"; -import type { SerializeFrom } from "./types/route-data"; +import type { + GetActionData, + GetLoaderData, + SerializeFrom, +} from "./types/route-data"; import type { unstable_ClientOnErrorFunction } from "./components"; +import type { RouteModules } from "./types/register"; /** * Resolves a URL against the current {@link Location}. @@ -1838,3 +1843,19 @@ function warningOnce(key: string, cond: boolean, message: string) { warning(false, message); } } + +type UseRoute = { + loaderData: GetLoaderData; + actionData: GetActionData; +}; +export function useRoute( + routeId: T, +): UseRoute | undefined { + const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData); + const route = state.matches.find(({ route }) => route.id === routeId); + if (route === undefined) return undefined; + return { + loaderData: state.loaderData[routeId], + actionData: state.actionData?.[routeId], + }; +} diff --git a/packages/react-router/lib/types/register.ts b/packages/react-router/lib/types/register.ts index a5bbd7f92b..51dbacf4a4 100644 --- a/packages/react-router/lib/types/register.ts +++ b/packages/react-router/lib/types/register.ts @@ -1,3 +1,5 @@ +import type { RouteModule } from "./route-module"; + /** * Apps can use this interface to "register" app-wide types for React Router via interface declaration merging and module augmentation. * React Router should handle this for you via type generation. @@ -7,6 +9,7 @@ export interface Register { // pages // routeFiles + // routeModules } // pages @@ -25,3 +28,10 @@ export type RouteFiles = Register extends { } ? Registered : AnyRouteFiles; + +type AnyRouteModules = Record; +export type RouteModules = Register extends { + routeModules: infer Registered extends AnyRouteModules; +} + ? Registered + : AnyRouteModules; From a01a87fc4681cf94a98afce1fbccd562c8c0f818 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Oct 2025 17:38:18 -0400 Subject: [PATCH 03/10] useRoute: use current route ID when route ID is not provided --- packages/react-router/lib/hooks.tsx | 33 +++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 330f84ce13..89a90f5728 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1287,6 +1287,7 @@ enum DataRouterStateHook { UseRevalidator = "useRevalidator", UseNavigateStable = "useNavigate", UseRouteId = "useRouteId", + UseRoute = "useRoute", } function getDataRouterConsoleError( @@ -1844,18 +1845,28 @@ function warningOnce(key: string, cond: boolean, message: string) { } } -type UseRoute = { - loaderData: GetLoaderData; - actionData: GetActionData; -}; -export function useRoute( - routeId: T, -): UseRoute | undefined { +type UseRoute = + | { + loaderData: GetLoaderData; + actionData: GetActionData; + } + | (RouteId extends "root" ? never : undefined); + +export function useRoute( + routeId?: RouteId, +): UseRoute { const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData); - const route = state.matches.find(({ route }) => route.id === routeId); - if (route === undefined) return undefined; + + const currentRouteId: keyof RouteModules = useCurrentRouteId( + DataRouterStateHook.UseRoute, + ); + const id: keyof RouteModules = routeId ?? currentRouteId; + + const route = state.matches.find(({ route }) => route.id === id); + + if (route === undefined) return undefined as UseRoute; return { - loaderData: state.loaderData[routeId], - actionData: state.actionData?.[routeId], + loaderData: state.loaderData[id], + actionData: state.actionData?.[id], }; } From 5e013e6b044e6d052dfb7f6cd8babecc8355205f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Oct 2025 17:55:36 -0400 Subject: [PATCH 04/10] wip --- packages/react-router/lib/hooks.tsx | 34 +++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 89a90f5728..fb9eee5982 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1845,28 +1845,34 @@ function warningOnce(key: string, cond: boolean, message: string) { } } -type UseRoute = - | { - loaderData: GetLoaderData; - actionData: GetActionData; - } - | (RouteId extends "root" ? never : undefined); - -export function useRoute( - routeId?: RouteId, -): UseRoute { - const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData); +type UseRouteArgs = [] | [routeId: keyof RouteModules]; + +// prettier-ignore +type UseRouteResult = + Args extends [] ? unknown : + Args extends ["root"] ? UseRoute<"root"> : + Args extends [infer RouteId extends keyof RouteModules] ? UseRoute | undefined : + never; + +type UseRoute = { + loaderData: GetLoaderData; + actionData: GetActionData; +}; +export function useRoute( + ...args: Args +): UseRouteResult { const currentRouteId: keyof RouteModules = useCurrentRouteId( DataRouterStateHook.UseRoute, ); - const id: keyof RouteModules = routeId ?? currentRouteId; + const id: keyof RouteModules = args[0] ?? currentRouteId; + const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData); const route = state.matches.find(({ route }) => route.id === id); - if (route === undefined) return undefined as UseRoute; + if (route === undefined) return undefined as UseRouteResult; return { loaderData: state.loaderData[id], actionData: state.actionData?.[id], - }; + } as UseRouteResult; } From 90244e29da3ee14d817a783b4be887d33b8d7602 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Oct 2025 18:03:11 -0400 Subject: [PATCH 05/10] useRoute: better types - no route ID -> { loaderData: unknown, actionData: unknown } - actionData gets `| undefined` added to it - `root` route is guaranteed to exist --- packages/react-router/lib/hooks.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index fb9eee5982..c2b1475321 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1849,14 +1849,18 @@ type UseRouteArgs = [] | [routeId: keyof RouteModules]; // prettier-ignore type UseRouteResult = - Args extends [] ? unknown : + Args extends [] ? UseRoute : Args extends ["root"] ? UseRoute<"root"> : Args extends [infer RouteId extends keyof RouteModules] ? UseRoute | undefined : never; -type UseRoute = { - loaderData: GetLoaderData; - actionData: GetActionData; +type UseRoute = { + loaderData: RouteId extends keyof RouteModules + ? GetLoaderData + : unknown; + actionData: RouteId extends keyof RouteModules + ? GetActionData | undefined + : unknown; }; export function useRoute( From 3ce0df787033986fea57e2429e0bcd7fa5b68253 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Oct 2025 18:04:35 -0400 Subject: [PATCH 06/10] useRoute: mark as unstable --- packages/react-router/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index e8e76914ec..fe3c25cea6 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -144,7 +144,7 @@ export { useRouteError, useRouteLoaderData, useRoutes, - useRoute, + useRoute as unstable_useRoute, } from "./lib/hooks"; // Expose old RR DOM API From c19b79d0b9545e0c44f1372d18f8fa81c3c3842c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Oct 2025 14:53:53 -0400 Subject: [PATCH 07/10] useRoute: change `loaderData` to be optional since it could be accessed within an error boundary when the loader itself failed --- packages/react-router/lib/hooks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index c2b1475321..259f75ace4 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1856,7 +1856,7 @@ type UseRouteResult = type UseRoute = { loaderData: RouteId extends keyof RouteModules - ? GetLoaderData + ? GetLoaderData | undefined : unknown; actionData: RouteId extends keyof RouteModules ? GetActionData | undefined From 1011e75af16cd17224bc19985d0dbef6d8387025 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Oct 2025 17:03:18 -0400 Subject: [PATCH 08/10] changeset --- .changeset/six-lobsters-think.md | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .changeset/six-lobsters-think.md diff --git a/.changeset/six-lobsters-think.md b/.changeset/six-lobsters-think.md new file mode 100644 index 0000000000..33736eafb1 --- /dev/null +++ b/.changeset/six-lobsters-think.md @@ -0,0 +1,81 @@ +--- +"@react-router/dev": patch +"react-router": patch +--- + +New (unstable) `useRoute` hook for accessing data from specific routes + +`useRouteLoaderData` has many shortcomings: + +1. Its `routeId` arg is typed as `string`, so TS won't complain if you pass in a non-existent route ID +2. Type-safety was limited to a `typeof loader` generic that required you to manually import and pass in the type for the corresponding `loader` +3. Even with `typeof loader`, the types were not aware of `clientLoader`, `HydrateFallback`, and `clientLoader.hydrate = true`, which all affect the type for `loaderData` +4. It is limited solely to `loader` data, but does not provide `action` data +5. It introduced confusion about when to use `useLoaderData` and when to use `useRouteLoaderData` + +```ts +// app/routes/admin.tsx +export const loader = () => ({ message: "Hello, loader!" }); + +export const action = () => ({ message: "Hello, action!" }); +``` + +```ts +import { type loader } from "../routes/admin"; + +export function Widget() { + const loaderData = useRouteLoaderData("routes/admin"); + // ... +} +``` + +With `useRoute`, all of these concerns have been fixed: + +```ts +import { unstable_useRoute as useRoute } from "react-router"; + +export function Widget() { + const admin = useRoute("routes/admin"); + console.log(admin?.loaderData?.message); + console.log(admin?.actionData?.message); + // ... +} +``` + +Note: `useRoute` returns `undefined` if the route is not part of the current page. + +The `root` route is special because it is guaranteed to be part of the current page, so no need to use `?.` or any other `undefined` checks: + +```ts +export function Widget() { + const root = useRoute("root"); + console.log(root.loaderData?.message, root.actionData?.message); + // ... +} +``` + +You may have noticed that `loaderData` and `actionData` are marked as optional. +This is intentional as there's no guarantee that `loaderData` nor `actionData` exists when called in certain contexts like within an `ErrorBoundary`: + +```ts +export function ErrorBoundary() { + const admin = useRoute("routes/admin"); + console.log(admin?.loaderData?.message); + // ^^ + // `loader` for itself could have thrown an error, + // so you need to check if `loaderData` exists! +} +``` + +In an effort to consolidate on fewer, more intuitive hooks, `useRoute` can be called without arguments as a replacement for `useLoaderData` and `useActionData`: + +```ts +export function Widget() { + const currentRoute = useRoute(); + currentRoute.loaderData; + currentRoute.actionData; +} +``` + +Since `Widget` is a reusable component that could be within any route, we have no guarantees about the types for `loaderData` nor `actionData`. +As a result, they are both typed as `unknown` and it is up to you to narrow the type to what your reusable component needs (for example, via `zod`). From 1a6f7d1eb4eaf6c103bc615a47022f154c6d721f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Oct 2025 17:31:20 -0400 Subject: [PATCH 09/10] updated changeset --- .changeset/six-lobsters-think.md | 109 +++++++++++++++++++------------ 1 file changed, 66 insertions(+), 43 deletions(-) diff --git a/.changeset/six-lobsters-think.md b/.changeset/six-lobsters-think.md index 33736eafb1..3db89c1017 100644 --- a/.changeset/six-lobsters-think.md +++ b/.changeset/six-lobsters-think.md @@ -5,77 +5,100 @@ New (unstable) `useRoute` hook for accessing data from specific routes -`useRouteLoaderData` has many shortcomings: +For example, let's say you have an `admin` route somewhere in your app and you want any child routes of `admin` to all have access to the `loaderData` and `actionData` from `admin.` -1. Its `routeId` arg is typed as `string`, so TS won't complain if you pass in a non-existent route ID -2. Type-safety was limited to a `typeof loader` generic that required you to manually import and pass in the type for the corresponding `loader` -3. Even with `typeof loader`, the types were not aware of `clientLoader`, `HydrateFallback`, and `clientLoader.hydrate = true`, which all affect the type for `loaderData` -4. It is limited solely to `loader` data, but does not provide `action` data -5. It introduced confusion about when to use `useLoaderData` and when to use `useRouteLoaderData` - -```ts +```tsx // app/routes/admin.tsx -export const loader = () => ({ message: "Hello, loader!" }); +import { Outlet } from "react-router"; -export const action = () => ({ message: "Hello, action!" }); -``` +export const loader = () => ({ message: "Hello, loader!" }); -```ts -import { type loader } from "../routes/admin"; +export const action = () => ({ count: 1 }); -export function Widget() { - const loaderData = useRouteLoaderData("routes/admin"); - // ... +export default function Component() { + return ( +
+ {/* ... */} + + {/* ... */} +
+ ); } ``` -With `useRoute`, all of these concerns have been fixed: +You might even want to create a reusable widget that all of the routes nested under `admin` could use: -```ts +```tsx import { unstable_useRoute as useRoute } from "react-router"; -export function Widget() { - const admin = useRoute("routes/admin"); - console.log(admin?.loaderData?.message); - console.log(admin?.actionData?.message); - // ... +export function AdminWidget() { + // How to get `message` and `count` from `admin` route? } ``` -Note: `useRoute` returns `undefined` if the route is not part of the current page. +In framework mode, `useRoute` knows all your app's routes and gives you TS errors when invalid route IDs are passed in: + +```tsx +export function AdminWidget() { + const admin = useRoute("routes/dmin"); + // ^^^^^^^^^^^ +} +``` -The `root` route is special because it is guaranteed to be part of the current page, so no need to use `?.` or any other `undefined` checks: +`useRoute` returns `undefined` if the route is not part of the current page: -```ts -export function Widget() { - const root = useRoute("root"); - console.log(root.loaderData?.message, root.actionData?.message); - // ... +```tsx +export function AdminWidget() { + const admin = useRoute("routes/admin"); + if (!admin) { + throw new Error(`AdminWidget used outside of "routes/admin"`); + } } ``` -You may have noticed that `loaderData` and `actionData` are marked as optional. -This is intentional as there's no guarantee that `loaderData` nor `actionData` exists when called in certain contexts like within an `ErrorBoundary`: +Note: the `root` route is the exception since it is guaranteed to be part of the current page. +As a result, `useRoute` never returns `undefined` for `root`. -```ts -export function ErrorBoundary() { +`loaderData` and `actionData` are marked as optional since they could be accessed before the `action` is triggered or after the `loader` threw an error: + +```tsx +export function AdminWidget() { const admin = useRoute("routes/admin"); - console.log(admin?.loaderData?.message); - // ^^ - // `loader` for itself could have thrown an error, - // so you need to check if `loaderData` exists! + if (!admin) { + throw new Error(`AdminWidget used outside of "routes/admin"`); + } + const { loaderData, actionData } = admin; + console.log(loaderData); + // ^? { message: string } | undefined + console.log(actionData); + // ^? { count: number } | undefined } ``` -In an effort to consolidate on fewer, more intuitive hooks, `useRoute` can be called without arguments as a replacement for `useLoaderData` and `useActionData`: +If instead of a specific route, you wanted access to the _current_ route's `loaderData` and `actionData`, you can call `useRoute` without arguments: -```ts -export function Widget() { +```tsx +export function AdminWidget() { const currentRoute = useRoute(); currentRoute.loaderData; currentRoute.actionData; } ``` -Since `Widget` is a reusable component that could be within any route, we have no guarantees about the types for `loaderData` nor `actionData`. -As a result, they are both typed as `unknown` and it is up to you to narrow the type to what your reusable component needs (for example, via `zod`). +This usage is equivalent to calling `useLoaderData` and `useActionData`, but consolidates all route data access into one hook: `useRoute`. + +Note: when calling `useRoute()` (without a route ID), TS has no way to know which route is the current route. +As a result, `loaderData` and `actionData` are typed as `unknown`. +If you want more type-safety, you can either narrow the type yourself with something like `zod` or you can refactor your app to pass down typed props to your `AdminWidget`: + +```tsx +export function AdminWidget({ + message, + count, +}: { + message: string; + count: number; +}) { + /* ... */ +} +``` From a2bb8ab0803aee3f6ea37cc8e6e63837e12ca07d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 3 Oct 2025 14:18:34 -0400 Subject: [PATCH 10/10] useRoute: testing --- integration/use-route-test.ts | 118 ++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 integration/use-route-test.ts diff --git a/integration/use-route-test.ts b/integration/use-route-test.ts new file mode 100644 index 0000000000..44b3bca2cb --- /dev/null +++ b/integration/use-route-test.ts @@ -0,0 +1,118 @@ +import tsx from "dedent"; +import { expect } from "@playwright/test"; + +import { test } from "./helpers/fixtures"; +import * as Stream from "./helpers/stream"; +import getPort from "get-port"; + +test.use({ + files: { + "app/expect-type.ts": tsx` + export type Expect = T + + export type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false + `, + "app/routes.ts": tsx` + import { type RouteConfig, route } from "@react-router/dev/routes" + + export default [ + route("parent", "routes/parent.tsx", [ + route("current", "routes/current.tsx") + ]), + route("other", "routes/other.tsx"), + ] satisfies RouteConfig + `, + "app/root.tsx": tsx` + import { Outlet } from "react-router" + + export const loader = () => ({ rootLoader: "root/loader" }) + export const action = () => ({ rootAction: "root/action" }) + + export default function Component() { + return ( + <> +

Root

+ + + ) + } + `, + "app/routes/parent.tsx": tsx` + import { Outlet } from "react-router" + + export const loader = () => ({ parentLoader: "parent/loader" }) + export const action = () => ({ parentAction: "parent/action" }) + + export default function Component() { + return ( + <> +

Parent

+ + + ) + } + `, + "app/routes/current.tsx": tsx` + import { unstable_useRoute as useRoute } from "react-router" + + import type { Expect, Equal } from "../expect-type" + + export const loader = () => ({ currentLoader: "current/loader" }) + export const action = () => ({ currentAction: "current/action" }) + + export default function Component() { + const current = useRoute() + type Test1 = Expect> + + const root = useRoute("root") + type Test2 = Expect> + + const parent = useRoute("routes/parent") + type Test3 = Expect> + + const other = useRoute("routes/other") + type Test4 = Expect> + + return ( + <> +
{root.loaderData?.rootLoader}
+
{parent?.loaderData?.parentLoader}
+ {/* @ts-expect-error */} +
{current?.loaderData?.currentLoader}
+
{other === undefined ? "undefined" : "something else"}
+ + ) + } + `, + "app/routes/other.tsx": tsx` + export const loader = () => ({ otherLoader: "other/loader" }) + export const action = () => ({ otherAction: "other/action" }) + + export default function Component() { + return

Other

+ } + `, + }, +}); + +test("useRoute", async ({ $, page }) => { + await $("pnpm typecheck"); + + const port = await getPort(); + const url = `http://localhost:${port}`; + + const dev = $(`pnpm dev --port ${port}`); + await Stream.match(dev.stdout, url); + + await page.goto(url + "/parent/current", { waitUntil: "networkidle" }); + + await expect(page.locator("[data-root]")).toHaveText("root/loader"); + + await expect(page.locator("[data-parent]")).toHaveText("parent/loader"); + + await expect(page.locator("[data-current]")).toHaveText("current/loader"); + + await expect(page.locator("[data-other]")).toHaveText("undefined"); +});