Routing without intermediate renders
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 was simple: remove visible re-renders and unstable navigation. What starts as a small visual correction quickly turns 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. Which sections are visible depends on configuration that is first loaded from the backend.
The navigation roughly looks like this:
/runtime/item/:type/:id
/runtime/item/:type/:id/overview
/runtime/item/:type/:id/sections/:index
On paper, simple. In practice, more complex.
The problem that was not really broken
There were no errors. No crashes. Everything worked, but the interface felt unstable.
When opening an item, the following sometimes happened.
The page rendered immediately, but without complete state. Text appeared and changed. Elements shifted. A default view was rendered and then replaced by another section. When switching items, old information briefly remained visible before the new state took over.
What you saw was not a literal visual flash, but an initial render with incomplete bindings, followed by a second render once configuration and entity data became available.
The DOM was built while the required state was not yet fully ready. As soon as the configuration arrived, the layout corrected itself.
This is not a performance issue.
It is a synchronization issue between routing and state initialization.
How it originally worked
In the original situation, data was loaded from within the component:
effect(() => {
// Component driven loading.
// This runs after the component is created, so the first render can happen
// before data and layout are ready.
const id = this.route.snapshot.paramMap.get('id');
if (id) {
this.facade.loadForRoute(id);
}
});
That feels logical. The component knows the route, so the component loads the data.
But by the time this code runs, the first render has already happened. Everything that arrives afterwards causes another render, sometimes multiple.
The decision about which view should be shown is made only after the UI is already visible. which is too late
Loading data before the component renders
The core of the solution is shifting responsibility. Instead of loading data inside the component, we let the router wait until everything is ready.
Angular provides resolvers and ResolveFn for this.
A resolver runs during the routing phase and can block navigation until data is available.
Route configuration:
{
path: '',
component: DashboardComponent,
providers: [DashboardFacade],
resolve: {
// Ensures entity data and layout configuration
// are fully loaded before the component is created.
prefetch: dashboardPrefetchResolver,
},
}
The facade contains a method that only completes when the application is truly 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));
}
The router now waits until this stream completes.
That means:
- The entity is loaded
- The configuration is fetched
- The layout is initialized
Only then is the component created. There are no intermediate renders and no layout that corrects itself afterwards.
Routing now synchronizes before the first render, not after.
An additional benefit is that this is a natural place to attach a universal loader. As long as the resolver is active, the application can show one consistent loading state. The user can only interact once the resolver has completed and the loader disappears.
Why signals are not used here
Signals are ideal for reactive state inside components and services, but resolvers work differently. The router must explicitly wait until something is finished. That means a resolver must return a Promise or Observable that completes.
Signals do not have a completion moment. They represent a current value, but they do not complete, and completion is exactly what the router needs to proceed.
That is why we use observables inside the resolver, even if the internal state is modeled with signals:
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 is fully determined by backend configuration. We do not receive a fixed list of child routes with unique identifiers. The structure of the screen only exists after the configuration has been fetched and initialized.
Angular determines child routes during route matching. See the official routing guide.
At that moment, only static route configuration is available. Runtime data is not.
That means we do not know in advance which sections are available. A fixed redirect would therefore not be correct.
Determining the default section in advance
That is why we use a default child resolver that determines which subroute should be active before activation.
{
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 });
};
RouteReuseStrategy and entity switches
Angular reuses component instances by default when the route configuration remains the same. That behavior is defined by RouteReuseStrategy.
When only the id changes, Angular can reuse the same component instance. That is efficient, but it can leave old state briefly visible.
That is why we implement a custom RouteReuseStrategy:
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 lifecycle restarts. Old bindings disappear immediately. State is rebuilt.
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 synchronization also solved flaky end-to-end tests:
cy.waitForPageReady({ key: 'dashboard' });
cy.get('[data-ta="name-input"]').type('Example');
In closing
Routing is not just navigation. It is timing.
When data is loaded inside a component, the UI corrects itself afterwards.
When data is loaded inside a resolver, the UI is correct from the first moment.
It is an architectural choice, and stability feels like speed to users.
Frameworks at Crossroads Europalaan 93, 3526 KP Utrecht