Routing without intermediate renders

by Mylene — 7 minutes

How resolvers, lifecycle and route reuse work together to create stable navigation in Angular

Routing in Angular usually feels straightforward. You define routes, attach a component, maybe add a resolver, and you are done.

Until you step into an existing project where a navigation pattern is already in place and you only intend to fix a bug. In our case the goal sounded simple: remove visible re renders and unstable navigation. What started as a small visual correction quickly turned into a deeper routing and synchronization problem.

We are working on a dashboard whose layout is fully configuration driven. At the top of the screen there are multiple sections such as Overview, Details or History, and which sections are visible depends on configuration that is first loaded from the backend. The navigation itself looks simple:

/runtime/item/:type/:id
/runtime/item/:type/:id/overview
/runtime/item/:type/:id/sections/:index

On paper this is straightforward, but in practice the behavior turned out to be more complex.

The problem that was not really broken

There were no errors, no crashes, and nothing that looked obviously wrong in the code. Still, the interface felt unstable.

When opening an item the page sometimes rendered immediately without complete state. Text appeared and changed, elements shifted, and a default view could briefly be visible before being replaced by another section. When switching between items, old information sometimes remained visible for a moment before the new state took over.

What you saw was not a literal visual flash, but an initial render with incomplete bindings followed by another render once configuration and entity data became available. The DOM was built while the required state was not yet ready, and as soon as the configuration arrived the layout corrected itself.

This is not a performance problem.
It is a synchronization problem between routing and state initialization.

How it originally worked

In the original situation, data was loaded from inside the component itself.

effect(() => {
  const id = this.route.snapshot.paramMap.get('id');

  if (id) {
    this.facade.loadForRoute(id);
  }
});

This feels logical. The component knows the route, so the component loads the data.
The problem is that this code runs after the component has already been created, which means the first render can happen before the data and layout are ready. Everything that arrives afterwards triggers another render, and sometimes multiple renders in a row.

The decision about which view should be shown is made only after the UI is already visible, and that is simply too late.

Loading data before the component renders

The solution was to shift responsibility from the component to the router. Instead of letting the component load its own data, we let the router wait until everything is ready before the component is created.

Angular provides resolvers and ResolveFn for exactly this purpose. A resolver runs during navigation and can block activation until the required data is available.

Route configuration:

{
  path: '',
  component: DashboardComponent,
  providers: [DashboardFacade],
  resolve: {
    prefetch: dashboardPrefetchResolver,
  },
}

The facade exposes a method that only completes when the application state is fully ready.

prefetch$(route: ActivatedRouteSnapshot): Observable<void> {
  const id = route.paramMap.get('id');
  const type = route.paramMap.get('type');

  if (id &amp;&amp; type) {
    this.loadForRoute(type, id);
  }

  return this.isReady$.pipe(
    filter(Boolean),
    take(1)
  );
}

Because the resolver waits for this stream to complete, the router does not create the component until the entity, configuration and layout are all initialized. The result is that the UI renders only once, and it renders with the correct state.

Another advantage of this approach is that the resolver is a natural place to show a global loader. As long as the resolver is running, the application can stay in one consistent loading state, and the user only sees the screen once everything is ready.

Why signals are not used inside the resolver

Signals work very well for reactive state inside components and services, but resolvers have a different requirement. The router must know when something is finished, which means a resolver has to return a Promise or an Observable that completes.

Signals represent the current value of state, but they do not complete. Because of that, the router cannot use a signal directly to decide when navigation may continue.

That is why the resolver converts the signal to an observable:

import { toObservable } from '@angular/core/rxjs-interop';

return toObservable(this.isReady).pipe(
  filter(Boolean),
  take(1)
);

Signals determine when the UI reacts.
Resolvers determine when routing continues.

Why a simple redirect did not work

The layout of this dashboard is fully determined by backend configuration. We do not receive a fixed list of child routes with known identifiers. The structure of the screen only exists after the configuration has been fetched and processed.

Angular determines child routes during route matching, and at that moment only static route configuration is available. Runtime data does not exist yet, so the router cannot know which sections will be visible.

Because of that, a fixed redirect in the route configuration would not always be correct.

Determining the default section before activation

Instead of using a static redirect, we use a resolver on the default child route to determine which subroute should be activated.

{
  path: '',
  component: EmptyRouteComponent,
  resolve: {
    defaultSection: dashboardDefaultChildResolver,
  },
}
export const dashboardDefaultChildResolver: ResolveFn<UrlTree> = (route) => {
  const layout = inject(LayoutService);
  const router = inject(Router);

  const sections = layout.getSections()();

  if (sections?.length) {
    return router.createUrlTree(
      ['sections', 0],
      { relativeTo: route.parent }
    );
  }

  return router.createUrlTree(
    ['overview'],
    { relativeTo: route.parent }
  );
};

Because this runs before activation, the router always navigates to the correct section without showing an intermediate state.

RouteReuseStrategy and entity switches

Angular reuses component instances by default when the route configuration stays the same. This behavior is defined by RouteReuseStrategy. Reusing components is efficient, but when only the id changes it can briefly show old state before the new data arrives.

To avoid that, we implemented a custom reuse strategy.

export class CustomRouteReuseStrategy implements RouteReuseStrategy {

  shouldReuseRoute(
    future: ActivatedRouteSnapshot,
    current: ActivatedRouteSnapshot
  ): boolean {
    return (
      future.routeConfig === current.routeConfig &amp;&amp;
      future.paramMap.get('id') === current.paramMap.get('id')
    );
  }

  shouldDetach() {
    return false;
  }

  store() {}

  shouldAttach() {
    return false;
  }

  retrieve() {
    return null;
  }
}

When the id changes, the component lifecycle restarts completely. Old bindings disappear immediately and the state is rebuilt from scratch.

Before and after

Before After
route match route match
component init reuse check
data load resolver
layout init layout init
redirect determine default section
second render component init
one render

The router decides first.
The UI renders only when everything is correct.

This change also solved several flaky end to end tests, because the page is now stable before interaction starts.

cy.waitForPageReady({ key: 'dashboard' });

cy.get('[data-ta="name-input"]')
  .type('Example');

In closing

Routing is not only about navigation, it is also about timing.

When data is loaded inside a component, the UI often corrects itself afterwards. When data is loaded inside a resolver, the UI is correct from the first render.

It is a small architectural change, but it makes the application feel much more stable. And to users, stability feels like speed.

meerdivotion

Cases

Blogs

Event