diff --git a/adev/src/content/guide/routing/data-fetching-with-resources.md b/adev/src/content/guide/routing/data-fetching-with-resources.md new file mode 100644 index 00000000000..9c4b23deac0 --- /dev/null +++ b/adev/src/content/guide/routing/data-fetching-with-resources.md @@ -0,0 +1,217 @@ +# Data fetching with resources + +The Angular Router integrates with Angular Signals through the `resources` route configuration. This allows you to fetch data reactively using `Resource` APIs. + +## Setup + +To enable this feature, provide `withRouterResources()` to your router configuration: + +```ts +import {provideRouter, withComponentInputBinding, withRouterResources} from '@angular/router'; + +bootstrapApplication(App, { + providers: [provideRouter(routes, withComponentInputBinding(), withRouterResources())], +}); +``` + +You can then define resources in your `Route` definitions and access them directly as component inputs. + +The `resources` function runs in an injection context, allowing you to use `inject()` to access services, API clients, or stores directly inside the route definition. + +```angular-ts +import {Component, inject, input, resource} from '@angular/core'; +import {Routes} from '@angular/router'; +import {UserService} from './user.service'; + +const routes: Routes = [ + { + path: 'user/:id', + component: UserProfile, + resources: (ctx) => { + const userService = inject(UserService); + return { + user: resource({ + params: () => ctx.params()['id'], + loader: ({params: id}) => userService.getUser(id), + }), + }; + }, + }, +]; + +@Component({ + template: `

User: {{ user().name }}

`, +}) +export class UserProfile { + // The router automatically binds only the value for blocking resources. + user = input.required(); +} +``` + +TIP: Notice we map the exact primitive ID we need in `params: () => ctx.params()['id']`. Passing the entire parameters object (e.g. `params: () => ctx.params()`) can cause unnecessary resource reloads during navigations. Because the router generates a new object identity for the parameters on navigation, the resource will trigger a refetch even if the specific `id` value you care about hasn't changed. + +### ResourceContext + +The `resources` function receives a `ResourceContext` providing access to route signals (such as `params`, `queryParams`, and `data`) as well as the static `snapshot`. + +NOTE: Route resources execute in parallel and cannot access or depend on other resources defined on the route. If you need dependent data fetching, manage that sequence within a single resource's loader. + +### Resource implementations and async configuration + +The `resources` map supports any Angular `Resource` implementation (such as `resource()`, `rxResource()`, or custom resources). + +```ts +import {Routes} from '@angular/router'; +import {rxResource} from '@angular/core/rxjs-interop'; + +const routes: Routes = [ + { + path: 'user/:id', + component: UserProfile, + resources: (ctx) => ({ + user: rxResource({ + params: () => ctx.params()['id'], + stream: ({params: id}) => fetchUserObservable(id), + }), + }), + }, +]; +``` + +NOTE: `rxResource` uses the `stream` property instead of `loader` to accept a function that returns an Observable. + +The `resources` function can also be `async` and return a `Promise` if you need to perform asynchronous setup or dynamic imports before configuring resources: + +```ts +resources: async (ctx) => { + const {fetchUserData} = await import('./user-api'); + return { + user: resource({ + params: () => ctx.params()['id'], + loader: ({params: id}) => fetchUserData(id), + }), + }; +}, +``` + +## Accessing resources via ActivatedRoute + +When using `withComponentInputBinding()`, blocking resources bind only their unwrapped value directly to component inputs. If you need to interact with the underlying `Resource` instance (for example, to trigger a manual `.reload()` or inspect status signals), you can access it through `ActivatedRoute` or `ActivatedRouteSnapshot`. + +```angular-ts +import {Component, inject, input} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; + +@Component({ + template: ` +

User: {{ user().name }}

+ + `, +}) +export class UserProfile { + user = input.required(); + private userResource = inject(ActivatedRoute).resources?.['user']; + + reload() { + this.userResource?.reload(); + } +} +``` + +## Blocking and non-blocking resources + +By default, all resources returned from `resources` are **blocking**. The router waits until the data is fully loaded before activating the route and component. + +**For blocking resources, the router binds only the resolved value to the component input.** The input type in your component is `T` instead of `Resource`. + +This simplifies your component because it does not need to handle loading or error states. Because the router blocks navigation until the resource is loaded, the component never observes a `loading` state. If the resource throws an error, the router cancels the navigation, so the component never observes an `error` state. + +If you prefer to handle loading states in the UI, use the `nonBlocking()` wrapper utility. Non-blocking resources do not halt navigation. The router activates the component immediately, allowing the UI to handle loading or skeleton states. + +**For non-blocking resources, the router binds the full `Resource` object to the component input.** This allows you to access `.isLoading()`, `.error()`, and other resource signals in your component. + +```angular-ts +import {Component, input, Resource, resource} from '@angular/core'; +import {Routes, nonBlocking} from '@angular/router'; + +const routes: Routes = [ + { + path: 'reports', + component: Reports, + resources: (ctx) => ({ + reportData: nonBlocking( + resource({ + loader: () => fetchHeavyReportData(), + }), + ), + }), + }, +]; + +@Component({ + template: ` + @if (reportData().isLoading()) { +

Loading...

+ } @else if (reportData().error()) { +

Error loading report.

+ } @else if (reportData().hasValue()) { + + } + `, +}) +export class Reports { + reportData = input.required>(); +} +``` + +NOTE: If a blocking resource throws an error, the router cancels the navigation and emits a `NavigationError` event. Resources wrapped in `nonBlocking()` that error will complete navigation and expose the error via the `resource.error()` signal. + +### Redirecting from a resource + +If a blocking resource needs to redirect the user (for example, if an item is not found), throw a `RedirectCommand` inside the resource loader. The router will cancel the current navigation and redirect to the specified URL: + +```ts +import {inject, resource} from '@angular/core'; +import {RedirectCommand, Router, Routes} from '@angular/router'; + +const routes: Routes = [ + { + path: 'user/:id', + component: UserProfile, + resources: (ctx) => { + const router = inject(Router); + + return { + user: resource({ + params: () => ctx.params()['id'], + loader: async ({params: id}) => { + const user = await fetchUser(id); + if (!user) { + throw new RedirectCommand(router.parseUrl('/not-found')); + } + return user; + }, + }), + }; + }, + }, +]; +``` + +## Transitional states during pending navigations + +When moving between views (or reloading the same view with new parameters), switching abruptly to a loading skeleton can create a jarring UI flash. + +The router automatically masks intermediate `loading` and `reloading` states of resolved resources while a navigation is pending. + +If you navigate from `/user/1` to `/user/2`, `UserProfile` stays mounted and continues rendering data from `/user/1` (frozen in its exact state) until `/user/2` resolves. Once `/user/2` settles, the router unfreezes the UI, transitioning directly to the new data with no loading flash. + +NOTE: Route resources returned to the router are read-only. Manual `.reload()` calls attempted during an active navigation transition or rollback recovery return `false` to avoid interrupting router transition tracking. + +### Rollback recovery on cancellation + +If a navigation is cancelled (for example, by a guard), the router reverts the state tree to the previous state. This reversion can cause the resource's signal dependencies (such as route parameters) to revert to their previous values. + +Because the parameters changed back, the resource might automatically trigger a new load to fetch data for the old parameters. To prevent flashing a loading state for data that was already visible, the router retains the previous resource snapshot in the UI until the resource has settled in the reverted state. + +TIP: Forward the `abortSignal` provided by the resource loader to your asynchronous calls (like `fetch`). When the router rolls back parameters or supersedes navigations, the pending request is cleanly aborted: `loader: ({params: id, abortSignal}) => fetchUser(id, {signal: abortSignal})`. diff --git a/packages/router/test/router_resource_spec.ts b/packages/router/test/router_resource_spec.ts index b2d30781ead..3d0e7e3c6d1 100644 --- a/packages/router/test/router_resource_spec.ts +++ b/packages/router/test/router_resource_spec.ts @@ -774,6 +774,30 @@ describe('Router resources integration', () => { expect(handleCount).toBe(1); expect((errorRef as Error).message).toBe('Resource failed!'); }); + + it('should redirect when a blocking resource throws a RedirectCommand', async () => { + const {harness, router} = await setupRouter([ + { + path: 'test', + component: TargetCmp, + resources: () => ({ + data: resource({ + loader: async () => { + throw new RedirectCommand(TestBed.inject(Router).parseUrl('/redirected')); + }, + }), + }), + }, + { + path: 'redirected', + component: TargetCmp, + }, + ]); + + await harness.navigateByUrl('/test'); + + expect(router.url).toBe('/redirected'); + }); }); describe('rxResource Integration', () => {