Skip to content

Commit 45bad2b

Browse files
authored
unstable_useRoute (#14407)
* typegen: register lookup from route ID to route module * useRoute: type-safe data access for other routes on the page * useRoute: use current route ID when route ID is not provided * wip * useRoute: better types - no route ID -> { loaderData: unknown, actionData: unknown } - actionData gets `| undefined` added to it - `root` route is guaranteed to exist * useRoute: mark as unstable * useRoute: change `loaderData` to be optional since it could be accessed within an error boundary when the loader itself failed * changeset * updated changeset * useRoute: testing
1 parent 368e0cb commit 45bad2b

File tree

6 files changed

+303
-2
lines changed

6 files changed

+303
-2
lines changed

.changeset/six-lobsters-think.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
---
2+
"@react-router/dev": patch
3+
"react-router": patch
4+
---
5+
6+
New (unstable) `useRoute` hook for accessing data from specific routes
7+
8+
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.`
9+
10+
```tsx
11+
// app/routes/admin.tsx
12+
import { Outlet } from "react-router";
13+
14+
export const loader = () => ({ message: "Hello, loader!" });
15+
16+
export const action = () => ({ count: 1 });
17+
18+
export default function Component() {
19+
return (
20+
<div>
21+
{/* ... */}
22+
<Outlet />
23+
{/* ... */}
24+
</div>
25+
);
26+
}
27+
```
28+
29+
You might even want to create a reusable widget that all of the routes nested under `admin` could use:
30+
31+
```tsx
32+
import { unstable_useRoute as useRoute } from "react-router";
33+
34+
export function AdminWidget() {
35+
// How to get `message` and `count` from `admin` route?
36+
}
37+
```
38+
39+
In framework mode, `useRoute` knows all your app's routes and gives you TS errors when invalid route IDs are passed in:
40+
41+
```tsx
42+
export function AdminWidget() {
43+
const admin = useRoute("routes/dmin");
44+
// ^^^^^^^^^^^
45+
}
46+
```
47+
48+
`useRoute` returns `undefined` if the route is not part of the current page:
49+
50+
```tsx
51+
export function AdminWidget() {
52+
const admin = useRoute("routes/admin");
53+
if (!admin) {
54+
throw new Error(`AdminWidget used outside of "routes/admin"`);
55+
}
56+
}
57+
```
58+
59+
Note: the `root` route is the exception since it is guaranteed to be part of the current page.
60+
As a result, `useRoute` never returns `undefined` for `root`.
61+
62+
`loaderData` and `actionData` are marked as optional since they could be accessed before the `action` is triggered or after the `loader` threw an error:
63+
64+
```tsx
65+
export function AdminWidget() {
66+
const admin = useRoute("routes/admin");
67+
if (!admin) {
68+
throw new Error(`AdminWidget used outside of "routes/admin"`);
69+
}
70+
const { loaderData, actionData } = admin;
71+
console.log(loaderData);
72+
// ^? { message: string } | undefined
73+
console.log(actionData);
74+
// ^? { count: number } | undefined
75+
}
76+
```
77+
78+
If instead of a specific route, you wanted access to the _current_ route's `loaderData` and `actionData`, you can call `useRoute` without arguments:
79+
80+
```tsx
81+
export function AdminWidget() {
82+
const currentRoute = useRoute();
83+
currentRoute.loaderData;
84+
currentRoute.actionData;
85+
}
86+
```
87+
88+
This usage is equivalent to calling `useLoaderData` and `useActionData`, but consolidates all route data access into one hook: `useRoute`.
89+
90+
Note: when calling `useRoute()` (without a route ID), TS has no way to know which route is the current route.
91+
As a result, `loaderData` and `actionData` are typed as `unknown`.
92+
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`:
93+
94+
```tsx
95+
export function AdminWidget({
96+
message,
97+
count,
98+
}: {
99+
message: string;
100+
count: number;
101+
}) {
102+
/* ... */
103+
}
104+
```

integration/use-route-test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import tsx from "dedent";
2+
import { expect } from "@playwright/test";
3+
4+
import { test } from "./helpers/fixtures";
5+
import * as Stream from "./helpers/stream";
6+
import getPort from "get-port";
7+
8+
test.use({
9+
files: {
10+
"app/expect-type.ts": tsx`
11+
export type Expect<T extends true> = T
12+
13+
export type Equal<X, Y> =
14+
(<T>() => T extends X ? 1 : 2) extends
15+
(<T>() => T extends Y ? 1 : 2) ? true : false
16+
`,
17+
"app/routes.ts": tsx`
18+
import { type RouteConfig, route } from "@react-router/dev/routes"
19+
20+
export default [
21+
route("parent", "routes/parent.tsx", [
22+
route("current", "routes/current.tsx")
23+
]),
24+
route("other", "routes/other.tsx"),
25+
] satisfies RouteConfig
26+
`,
27+
"app/root.tsx": tsx`
28+
import { Outlet } from "react-router"
29+
30+
export const loader = () => ({ rootLoader: "root/loader" })
31+
export const action = () => ({ rootAction: "root/action" })
32+
33+
export default function Component() {
34+
return (
35+
<>
36+
<h1>Root</h1>
37+
<Outlet />
38+
</>
39+
)
40+
}
41+
`,
42+
"app/routes/parent.tsx": tsx`
43+
import { Outlet } from "react-router"
44+
45+
export const loader = () => ({ parentLoader: "parent/loader" })
46+
export const action = () => ({ parentAction: "parent/action" })
47+
48+
export default function Component() {
49+
return (
50+
<>
51+
<h2>Parent</h2>
52+
<Outlet />
53+
</>
54+
)
55+
}
56+
`,
57+
"app/routes/current.tsx": tsx`
58+
import { unstable_useRoute as useRoute } from "react-router"
59+
60+
import type { Expect, Equal } from "../expect-type"
61+
62+
export const loader = () => ({ currentLoader: "current/loader" })
63+
export const action = () => ({ currentAction: "current/action" })
64+
65+
export default function Component() {
66+
const current = useRoute()
67+
type Test1 = Expect<Equal<typeof current, { loaderData: unknown, actionData: unknown }>>
68+
69+
const root = useRoute("root")
70+
type Test2 = Expect<Equal<typeof root, { loaderData: { rootLoader: string } | undefined, actionData: { rootAction: string } | undefined }>>
71+
72+
const parent = useRoute("routes/parent")
73+
type Test3 = Expect<Equal<typeof parent, { loaderData: { parentLoader: string } | undefined, actionData: { parentAction: string } | undefined } | undefined>>
74+
75+
const other = useRoute("routes/other")
76+
type Test4 = Expect<Equal<typeof other, { loaderData: { otherLoader: string } | undefined, actionData: { otherAction: string } | undefined } | undefined>>
77+
78+
return (
79+
<>
80+
<pre data-root>{root.loaderData?.rootLoader}</pre>
81+
<pre data-parent>{parent?.loaderData?.parentLoader}</pre>
82+
{/* @ts-expect-error */}
83+
<pre data-current>{current?.loaderData?.currentLoader}</pre>
84+
<pre data-other>{other === undefined ? "undefined" : "something else"}</pre>
85+
</>
86+
)
87+
}
88+
`,
89+
"app/routes/other.tsx": tsx`
90+
export const loader = () => ({ otherLoader: "other/loader" })
91+
export const action = () => ({ otherAction: "other/action" })
92+
93+
export default function Component() {
94+
return <h2>Other</h2>
95+
}
96+
`,
97+
},
98+
});
99+
100+
test("useRoute", async ({ $, page }) => {
101+
await $("pnpm typecheck");
102+
103+
const port = await getPort();
104+
const url = `http://localhost:${port}`;
105+
106+
const dev = $(`pnpm dev --port ${port}`);
107+
await Stream.match(dev.stdout, url);
108+
109+
await page.goto(url + "/parent/current", { waitUntil: "networkidle" });
110+
111+
await expect(page.locator("[data-root]")).toHaveText("root/loader");
112+
113+
await expect(page.locator("[data-parent]")).toHaveText("parent/loader");
114+
115+
await expect(page.locator("[data-current]")).toHaveText("current/loader");
116+
117+
await expect(page.locator("[data-other]")).toHaveText("undefined");
118+
});

packages/react-router-dev/typegen/generate.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,16 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
105105
interface Register {
106106
pages: Pages
107107
routeFiles: RouteFiles
108+
routeModules: RouteModules
108109
}
109110
}
110111
` +
111112
"\n\n" +
112113
Babel.generate(pagesType(allPages)).code +
113114
"\n\n" +
114-
Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code,
115+
Babel.generate(routeFilesType({ fileToRoutes, routeToPages })).code +
116+
"\n\n" +
117+
Babel.generate(routeModulesType(ctx)).code,
115118
};
116119

117120
// **/+types/*.ts
@@ -193,6 +196,29 @@ function routeFilesType({
193196
);
194197
}
195198

199+
function routeModulesType(ctx: Context) {
200+
return t.tsTypeAliasDeclaration(
201+
t.identifier("RouteModules"),
202+
null,
203+
t.tsTypeLiteral(
204+
Object.values(ctx.config.routes).map((route) =>
205+
t.tsPropertySignature(
206+
t.stringLiteral(route.id),
207+
t.tsTypeAnnotation(
208+
t.tsTypeQuery(
209+
t.tsImportType(
210+
t.stringLiteral(
211+
`./${Path.relative(ctx.rootDirectory, ctx.config.appDirectory)}/${route.file}`,
212+
),
213+
),
214+
),
215+
),
216+
),
217+
),
218+
),
219+
);
220+
}
221+
196222
function isInAppDirectory(ctx: Context, routeFile: string): boolean {
197223
const path = Path.resolve(ctx.config.appDirectory, routeFile);
198224
return path.startsWith(ctx.config.appDirectory);

packages/react-router/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export {
144144
useRouteError,
145145
useRouteLoaderData,
146146
useRoutes,
147+
useRoute as unstable_useRoute,
147148
} from "./lib/hooks";
148149

149150
// Expose old RR DOM API

packages/react-router/lib/hooks.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ import {
5050
resolveTo,
5151
stripBasename,
5252
} from "./router/utils";
53-
import type { SerializeFrom } from "./types/route-data";
53+
import type {
54+
GetActionData,
55+
GetLoaderData,
56+
SerializeFrom,
57+
} from "./types/route-data";
5458
import type { unstable_ClientOnErrorFunction } from "./components";
59+
import type { RouteModules } from "./types/register";
5560

5661
/**
5762
* Resolves a URL against the current {@link Location}.
@@ -1282,6 +1287,7 @@ enum DataRouterStateHook {
12821287
UseRevalidator = "useRevalidator",
12831288
UseNavigateStable = "useNavigate",
12841289
UseRouteId = "useRouteId",
1290+
UseRoute = "useRoute",
12851291
}
12861292

12871293
function getDataRouterConsoleError(
@@ -1838,3 +1844,39 @@ function warningOnce(key: string, cond: boolean, message: string) {
18381844
warning(false, message);
18391845
}
18401846
}
1847+
1848+
type UseRouteArgs = [] | [routeId: keyof RouteModules];
1849+
1850+
// prettier-ignore
1851+
type UseRouteResult<Args extends UseRouteArgs> =
1852+
Args extends [] ? UseRoute<unknown> :
1853+
Args extends ["root"] ? UseRoute<"root"> :
1854+
Args extends [infer RouteId extends keyof RouteModules] ? UseRoute<RouteId> | undefined :
1855+
never;
1856+
1857+
type UseRoute<RouteId extends keyof RouteModules | unknown> = {
1858+
loaderData: RouteId extends keyof RouteModules
1859+
? GetLoaderData<RouteModules[RouteId]> | undefined
1860+
: unknown;
1861+
actionData: RouteId extends keyof RouteModules
1862+
? GetActionData<RouteModules[RouteId]> | undefined
1863+
: unknown;
1864+
};
1865+
1866+
export function useRoute<Args extends UseRouteArgs>(
1867+
...args: Args
1868+
): UseRouteResult<Args> {
1869+
const currentRouteId: keyof RouteModules = useCurrentRouteId(
1870+
DataRouterStateHook.UseRoute,
1871+
);
1872+
const id: keyof RouteModules = args[0] ?? currentRouteId;
1873+
1874+
const state = useDataRouterState(DataRouterStateHook.UseRouteLoaderData);
1875+
const route = state.matches.find(({ route }) => route.id === id);
1876+
1877+
if (route === undefined) return undefined as UseRouteResult<Args>;
1878+
return {
1879+
loaderData: state.loaderData[id],
1880+
actionData: state.actionData?.[id],
1881+
} as UseRouteResult<Args>;
1882+
}

packages/react-router/lib/types/register.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { RouteModule } from "./route-module";
2+
13
/**
24
* Apps can use this interface to "register" app-wide types for React Router via interface declaration merging and module augmentation.
35
* React Router should handle this for you via type generation.
@@ -7,6 +9,7 @@
79
export interface Register {
810
// pages
911
// routeFiles
12+
// routeModules
1013
}
1114

1215
// pages
@@ -25,3 +28,10 @@ export type RouteFiles = Register extends {
2528
}
2629
? Registered
2730
: AnyRouteFiles;
31+
32+
type AnyRouteModules = Record<string, RouteModule>;
33+
export type RouteModules = Register extends {
34+
routeModules: infer Registered extends AnyRouteModules;
35+
}
36+
? Registered
37+
: AnyRouteModules;

0 commit comments

Comments
 (0)