Seamless Navigation in Angular: Pre-fetching Data with Route Resolvers and NgRx SignalStore

In modern web applications, the user experience during navigation is paramount. A common yet jarring experience is navigating to a new page only to be greeted by a loading spinner before the content appears. This article explains how to eliminate this pattern in Angular by pre-fetching data before a route is activated, ensuring your components are fully hydrated the moment they render.

We will achieve this using a powerful combination: the declarative state management of NgRx SignalStore and Angular's built-in Route Resolvers.

The What: The Problem of In-Component Data Fetching

First, let's define the problem we are solving. The most common pattern for fetching data in Angular is to do it within a component's ngOnInit lifecycle hook.

  1. A user clicks a link to navigate to /products.

  2. The Angular Router immediately destroys the old component and renders the ProductsComponent.

  3. The ProductsComponent's view appears, but it's empty. It shows a loading spinner or a blank space.

  4. Inside ngOnInit, a service method is called to fetch the products from an API.

  5. Once the HTTP request completes, the data is populated, and the component's view updates to display the products.

This sequence creates a noticeable "flicker" or "layout shift" where the user sees an incomplete view before the final content is displayed. Our goal is to change this sequence so that the data is ready before the component even exists.

The Why: The Superiority of the Resolver Pattern

Why should we move data fetching logic out of the component and into the routing layer? The benefits are significant and lead to a more robust and professional application architecture.

  1. Flawless User Experience: The primary advantage. The user navigates, and the new view appears instantly complete and fully rendered. The wait time is spent during the route transition itself (which can be indicated by a global progress bar, like on YouTube or GitHub), not within a half-rendered component.

  2. Smarter Architecture, "Dumber" Components: Components become simpler and more focused. Their only job is to display the state that is given to them. They become agnostic of how or when the data is fetched. This greatly simplifies their logic and makes them easier to test and reuse.

  3. Centralized Data Fetching Logic: By using a Resolver, you tie the data requirement to the route itself. This centralizes your data-loading logic within your routing configuration, making it clear what data is needed for which part of your application.

  4. Robust Error Handling: What if the API call fails? With a Resolver, you can gracefully cancel the navigation. The user stays on the current page, and you can display a global error notification. This is much cleaner than navigating to a broken page that is stuck in a permanent loading state.

The How: A Step-by-Step Implementation

Let's build this pattern step-by-step using SignalStore and a Route Resolver.

Step 1: The SignalStore with a Robust Asynchronous Method

First, we need a SignalStore with a method that both performs the data fetch and returns an Observable. The Angular Router will use this Observable to wait for the operation to complete.

// src/app/todos.store.ts
import { inject } from '@angular/core';
import { patchState, signalStore, withMethods } from '@ngrx/signals';
import { withEntities, setAllEntities } from '@ngrx/signals/entities';
import { tapResponse } from '@ngrx/operators';
import { Observable } from 'rxjs';
import { TodosService, Todo } from './todos.service'; // Your HTTP service

export const TodosStore = signalStore(
  { providedIn: 'root' },
  // Use withEntities for powerful collection management
  withEntities<Todo>(),
  withMethods((store, todosService = inject(TodosService)) => ({
    // This is the method we will call from our Resolver.
    // Crucially, it returns an Observable that the router can wait for.
    loadTodos(): Observable<Todo[]> {
      return todosService.getTodos().pipe(
        // tapResponse is a clean way to handle side effects like state updates
        tapResponse({
          // On success, update the store's state with the fetched entities
          next: (todos) => patchState(store, setAllEntities(todos)),
          // You can also handle errors here without breaking the stream
          error: console.error,
        })
      );
    },
  }))
);

Step 2: Create the Route Resolver Function

A Resolver is a simple, exportable function that acts as a "gatekeeper" for a route. The router executes this function and waits for the Observable it returns to complete before activating the route.

// src/app/todos.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { TodosStore } from './todos.store';
import { Observable } from 'rxjs';

// The resolver's return type is now Observable<any>
export const todosResolver: ResolveFn<any> = () => {
  const store = inject(TodosStore);

  // Call the store method AND RETURN the resulting Observable.
  // The router will now pause navigation and wait for this to complete.
  return store.loadTodos(); 
};

Step 3: Configure the Route

Now, we connect our todosResolver to a specific route in our application's routing configuration.

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { TodosComponent } from './todos/todos.component';
import { todosResolver } from './todos.resolver';

export const routes: Routes = [
  {
    path: 'todos',
    component: TodosComponent,
    // Add the `resolve` property here
    resolve: {
      // The key 'todos' is arbitrary. The value is our resolver function.
      todos: todosResolver,
    },
  },
  // ... other routes
];

Step 4: The Simplified Component

This is the payoff. Look how clean and simple our TodosComponent becomes. It has no ngOnInit for fetching data, no loading state management, and no complex logic. It trusts that when it is rendered, the TodosStore already contains the necessary state.

// src/app/todos/todos.component.ts
import { Component, inject } from '@angular/core';
import { TodosStore } from '../todos.store';

@Component({
  selector: 'app-todos',
  standalone: true,
  // imports: [/* Add necessary Angular imports here, like @for */],
  template: `
    <h2>My Todos</h2>

    <!-- 
      No need for @if (store.isLoading())!
      By the time this component renders, the data is already here,
      because the resolver waited for the loadTodos() call to complete.
    -->

    <ul>
      @for (todo of store.entities(); track todo.id) {
        <li>{{ todo.title }}</li>
      } @empty {
        <li>No todos found.</li>
      }
    </ul>
  `,
})
export class TodosComponent {
  // Simply inject the store and read from it.
  readonly store = inject(TodosStore);
}

Conclusion

By combining NgRx SignalStore with Angular's Route Resolvers, you can fundamentally improve your application's architecture and user experience. This pattern moves data-fetching logic to a more appropriate location, simplifies your components, and provides a seamless, professional navigation flow that users expect from high-quality applications. The next time you find yourself adding a loading spinner inside a component, consider promoting that logic to a Resolver and let the Angular Router do the heavy lifting for you.

0
Subscribe to my newsletter

Read articles from Pablo Rivas Sánchez directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pablo Rivas Sánchez
Pablo Rivas Sánchez

Seasoned Software Engineer | Microsoft Technology Specialist | Over a Decade of Expertise in Web Applications | Proficient in Angular & React | Dedicated to .NET Development & Promoting Unit Testing Practices