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
104 changes: 104 additions & 0 deletions .changeset/six-lobsters-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
"@react-router/dev": patch
"react-router": patch
---

New (unstable) `useRoute` hook for accessing data from specific routes

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.`

```tsx
// app/routes/admin.tsx
import { Outlet } from "react-router";

export const loader = () => ({ message: "Hello, loader!" });

export const action = () => ({ count: 1 });

export default function Component() {
return (
<div>
{/* ... */}
<Outlet />
{/* ... */}
</div>
);
}
```

You might even want to create a reusable widget that all of the routes nested under `admin` could use:

```tsx
import { unstable_useRoute as useRoute } from "react-router";

export function AdminWidget() {
// How to get `message` and `count` from `admin` route?
}
```

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");
// ^^^^^^^^^^^
}
```

`useRoute` returns `undefined` if the route is not part of the current page:

```tsx
export function AdminWidget() {
const admin = useRoute("routes/admin");
if (!admin) {
throw new Error(`AdminWidget used outside of "routes/admin"`);
}
}
```

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`.

`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");
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
}
```

If instead of a specific route, you wanted access to the _current_ route's `loaderData` and `actionData`, you can call `useRoute` without arguments:

```tsx
export function AdminWidget() {
const currentRoute = useRoute();
currentRoute.loaderData;
currentRoute.actionData;
}
```

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;
}) {
/* ... */
}
```
118 changes: 118 additions & 0 deletions integration/use-route-test.ts
Original file line number Diff line number Diff line change
@@ -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 extends true> = T

export type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => 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 (
<>
<h1>Root</h1>
<Outlet />
</>
)
}
`,
"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 (
<>
<h2>Parent</h2>
<Outlet />
</>
)
}
`,
"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<Equal<typeof current, { loaderData: unknown, actionData: unknown }>>

const root = useRoute("root")
type Test2 = Expect<Equal<typeof root, { loaderData: { rootLoader: string } | undefined, actionData: { rootAction: string } | undefined }>>

const parent = useRoute("routes/parent")
type Test3 = Expect<Equal<typeof parent, { loaderData: { parentLoader: string } | undefined, actionData: { parentAction: string } | undefined } | undefined>>

const other = useRoute("routes/other")
type Test4 = Expect<Equal<typeof other, { loaderData: { otherLoader: string } | undefined, actionData: { otherAction: string } | undefined } | undefined>>

return (
<>
<pre data-root>{root.loaderData?.rootLoader}</pre>
<pre data-parent>{parent?.loaderData?.parentLoader}</pre>
{/* @ts-expect-error */}
<pre data-current>{current?.loaderData?.currentLoader}</pre>
<pre data-other>{other === undefined ? "undefined" : "something else"}</pre>
</>
)
}
`,
"app/routes/other.tsx": tsx`
export const loader = () => ({ otherLoader: "other/loader" })
export const action = () => ({ otherAction: "other/action" })

export default function Component() {
return <h2>Other</h2>
}
`,
},
});

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");
});
28 changes: 27 additions & 1 deletion packages/react-router-dev/typegen/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,16 @@ export function generateRoutes(ctx: Context): Array<VirtualFile> {
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
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export {
useRouteError,
useRouteLoaderData,
useRoutes,
useRoute as unstable_useRoute,
} from "./lib/hooks";

// Expose old RR DOM API
Expand Down
44 changes: 43 additions & 1 deletion packages/react-router/lib/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -1282,6 +1287,7 @@ enum DataRouterStateHook {
UseRevalidator = "useRevalidator",
UseNavigateStable = "useNavigate",
UseRouteId = "useRouteId",
UseRoute = "useRoute",
}

function getDataRouterConsoleError(
Expand Down Expand Up @@ -1838,3 +1844,39 @@ function warningOnce(key: string, cond: boolean, message: string) {
warning(false, message);
}
}

type UseRouteArgs = [] | [routeId: keyof RouteModules];

// prettier-ignore
type UseRouteResult<Args extends UseRouteArgs> =
Args extends [] ? UseRoute<unknown> :
Args extends ["root"] ? UseRoute<"root"> :
Args extends [infer RouteId extends keyof RouteModules] ? UseRoute<RouteId> | undefined :
never;

type UseRoute<RouteId extends keyof RouteModules | unknown> = {
loaderData: RouteId extends keyof RouteModules
? GetLoaderData<RouteModules[RouteId]> | undefined
: unknown;
actionData: RouteId extends keyof RouteModules
? GetActionData<RouteModules[RouteId]> | undefined
: unknown;
};

export function useRoute<Args extends UseRouteArgs>(
...args: Args
): UseRouteResult<Args> {
const currentRouteId: keyof RouteModules = useCurrentRouteId(
DataRouterStateHook.UseRoute,
);
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 UseRouteResult<Args>;
return {
loaderData: state.loaderData[id],
actionData: state.actionData?.[id],
} as UseRouteResult<Args>;
}
10 changes: 10 additions & 0 deletions packages/react-router/lib/types/register.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -7,6 +9,7 @@
export interface Register {
// pages
// routeFiles
// routeModules
}

// pages
Expand All @@ -25,3 +28,10 @@ export type RouteFiles = Register extends {
}
? Registered
: AnyRouteFiles;

type AnyRouteModules = Record<string, RouteModule>;
export type RouteModules = Register extends {
routeModules: infer Registered extends AnyRouteModules;
}
? Registered
: AnyRouteModules;