diff --git a/.changeset/six-lobsters-think.md b/.changeset/six-lobsters-think.md
new file mode 100644
index 0000000000..3db89c1017
--- /dev/null
+++ b/.changeset/six-lobsters-think.md
@@ -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 (
+
+ {/* ... */}
+
+ {/* ... */}
+
+ );
+}
+```
+
+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;
+}) {
+ /* ... */
+}
+```
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");
+});
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);
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index ed1418c11e..fe3c25cea6 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -144,6 +144,7 @@ export {
useRouteError,
useRouteLoaderData,
useRoutes,
+ useRoute as unstable_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..259f75ace4 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}.
@@ -1282,6 +1287,7 @@ enum DataRouterStateHook {
UseRevalidator = "useRevalidator",
UseNavigateStable = "useNavigate",
UseRouteId = "useRouteId",
+ UseRoute = "useRoute",
}
function getDataRouterConsoleError(
@@ -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 [] ? UseRoute :
+ Args extends ["root"] ? UseRoute<"root"> :
+ Args extends [infer RouteId extends keyof RouteModules] ? UseRoute | undefined :
+ never;
+
+type UseRoute = {
+ loaderData: RouteId extends keyof RouteModules
+ ? GetLoaderData | undefined
+ : unknown;
+ actionData: RouteId extends keyof RouteModules
+ ? GetActionData | undefined
+ : unknown;
+};
+
+export function useRoute(
+ ...args: Args
+): UseRouteResult {
+ 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;
+ return {
+ loaderData: state.loaderData[id],
+ actionData: state.actionData?.[id],
+ } as UseRouteResult;
+}
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;