Reactive Scroll Position Restoration with RxJS

This is a reactive Angular solution via RxJS for simple applications that suffer from a common navigation issue. Similar implementations could be done in other frameworks too.

AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!
SPAs Struggle to Restore Scroll Position
JavaScript Single Page Applications rely on navigation libraries that use the History API to route through the application. A common issue is that scroll position isn’t as predictable inside of an SPA — scrolling down a page with lots of content and clicking a link places a user half way down on the next page instead of at the top, or when a user hits a back/forward button the last scrolled to position isn’t restored as expected.
The most popular and modern front end JavaScript frameworks (React, Vue, and Angular) all suffer from this UX issue. Most navigation library authors have left the management of scroll position restoration up to users because the desired experience might need to be personalized for each application.
Investigating Angular Options
Angular introduced the scrollPositionRestoration option in the RouterModule to help alleviate the headache of managing scroll position restoration. It works, but it’s hard to make one technique work for all applications, and this solution comes with caveats:
It only applies to the primary viewport. As such, it won’t record scroll offsets in a secondary route’s scrollable areas.
It cannot differentiate between navigation events in the primary and secondary router-outlets. As such, it will scroll back to the top of the page even when the primary route has not changed.
It cannot differentiate between navigation events that change the entire component tree and navigation events that only change a sub-section of the component tree (like a Tabbed Navigation). As such, it will scroll back to the top of the page even if the app doesn’t warrant it.
It has no inherent understanding of asynchronous data loading. As such, it will often try to restore a scroll offset before the necessary content is available on the page. This is true even if the data is cached in-memory and only needs to be applied to the component template. In other words, this really only applies to truly static data.
☝️ From Ben Nadel on Restoring And Resetting The Scroll Position Using The NavigationStart Event In Angular 7.0.4
In the Angular repo issue #24547 scrollPositionRestoration has several problems, the Angular team has responded to the requests of the community for a more viable solution, but for right now the work is remains unscheduled.
Brainstorming a Solution
For a simple application the main takeaways are that the scrollPositionRestoration won’t work well for:
- Content that is loaded in a secondary route to the viewport, like if the app has a fixed header/sidebar
- Content that is loaded asynchronously
- The application has pages that require scrolling to view all the content
One of my favorite things about working in an Angular application is it’s interoperability with RxJS. Angular is a reactive framework, so I wanted my solution to be reactive too, and as a reactive solution I want to try and follow these principles:
- Think of Everything as a Stream — We know the Router publishes events of the navigation cycle as an Observable stream, so we know when navigations starts and ends
- Declare What Instead of How — We can use RxJS operators to declare what we would like to happen with that stream of Router events in order to affect the scroll position
- Don’t Store What You Can Derive — We don’t have to micromanage storing information, we don’t need to store anything in a variable outside of a stream, all we need can be tracked in our functional stream
Angular Component Setup
import { Component } from '@angular/core';@Component({
selector: 'app-root',
template: `
<app-header></app-header>
<div>
<app-side-nav></app-side-nav>
<main>
<router-outlet></router-outlet>
</main>
</div>
`
})
export default class AppComponent {}All of the applications routes are being projected into the router-outlet, that’s where our dynamic content shows up. The main element will be scrolled when we scroll through the pages content. If we get a scrollTop of main, we know the Y scroll position of our dynamic content.
Next we’ll want to inject the Router into the component and subscribe to it’s Observable stream of events, we’ll do that inside the OnInit lifecycle hook. We can also set up the pipe off of the stream so that we can begin to craft our declarations with RxJS operators.
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';@Component({
selector: 'app-root',
template: `
<app-header></app-header>
<div>
<app-side-nav></app-side-nav>
<main>
<router-outlet></router-outlet>
</main>
</div>
`
})
export default class AppComponent implements OnInit {
constructor(private router: Router) {} ngOnInit() {
this.router.events.pipe(
// RxJS operators go here
).subscribe()
}
}Now we can start crafting our declarative stream with RxJS operators.
Declaring What to Do With The Events Observable
First, we only care about the events NavigationStart and NavigationEnd, so let’s import those and the RxJS operator filter. This will let us restrict the stream to only let the router events we care about travel through, based upon a predicate we define:
import { NavigationEnd, NavigationStart } from '@angular/router';
import { filter } from 'rxjs/operators';this.router.events.pipe(
filter(
event =>
event instanceof NavigationStart ||
event instanceof NavigationEnd,
),
)Next will be the largest of our functions where we’ll derive most of the information we need. We’re going to use the RxJS operator scan, and we want to model the output of that function so we can utilize TypeScript type inference further down the stream. Here’s the interface we’ll use:
import { Event } from '@angular/router';interface ScrollPositionRestore {
event: Event;
positions: { [K: number]: number };
trigger: 'imperative' | 'popstate';
idToRestore: number;
}This is also when we need to capture main's scrollTop value. To capture that we’ll use a ViewChild and query for a template reference, let’s set that up:
import { ElementRef, ViewChild } from '@angular/core';@Component({
selector: 'app-root',
template: `
<app-header>
<div>
<app-side-nav></app-side-nav>
<main #contentArea> // adding a template reference
<router-outlet></router-outlet>
</main>
</div>
`
})
export class AppComponent implements OnInit {
@ViewChild('contentArea')
private contentArea: ElementRef<HTMLMainElement>;Now for the scan, which is like a reduce over time.
import { scan } from 'rxjs/operators';
scan<Event, ScrollPositionRestore>((acc, event) => ({ event, // either NavigationStart or NavigationEnd positions: {
...acc.positions, // Spread out previously tracked positions
...(event instanceof NavigationStart
? { // get snapshot of scrollTop before nav cycle finishes
[event.id]: this.contentArea.nativeElement.scrollTop,
}
: {}),
}, trigger: // the type of nav, link click? back button?
event instanceof NavigationStart
? event.navigationTrigger
: acc.trigger, idToRestore:
// allows us to grab the correct position to restore
(event instanceof NavigationStart &&
event.restoredState &&
event.restoredState.navigationId + 1) ||
acc.idToRestore,})),
A few notes on the decisions made for this operation:
event— We keep theeventto be used in the next step of the stream.positions— Accumulated scroll positions are managed automatically by the scan, and this is when we grab a snapshot of the scrollTop before the navigation cycle completes. I chose anObjectinstead of anArraybecause it was easier to work with and didn’t require me to manage anything outside of the stream.trigger— This will be eitherimperativeorpopstate(at least for our simple application). If it’simperative, the user has clicked a link, and we’ll need to make sure the user is set to the top of the next page. If it’spopstate, the user has clicked a back/forward button in their browser, and we want to restore the correct scroll position.idToRestore— Every navigation cycle has anidassociated with it. The cycle begins with the NavigationStart event and for example it could have an{ id: 2 }. When we capture the scrollTop it’s associated with that{ id: 2 }, but the scrolling the user did was before that navigation cycle begins, so their scroll position is actually for the previous navigation cycle (which had already completed), in this example{ id: 1 }. When navigating via apopstatetrigger, there is anavigationIdin the events restoredState that refers to the correct navigation cycle where the scrolling occurred, but the scroll position is being mapped to the next cycle. Somewhere there needs to be a+or—of1, and I chose to do a+1here to match the scroll position with theidit was mapped to.
That work targeted what we needed to do for a NavigationStart event. For the next step we only care about the NavigationEnd event since we restore scroll position after the navigation cycle completes, and we want to make sure we have a trigger, so let’s use another filter:
filter(
({ event, trigger }) =>
event instanceof NavigationEnd && !!trigger,
),The last remaining pieces are to set the scrollTop correctly and to make sure that the work is fired at the correct time. We’re dealing with dynamically loaded content and we need to scroll properly after content has been loaded. First let’s see what setting the scrollTop looks like in the subscription block:
.subscribe(({ trigger, positions, idToRestore }) => {
if (trigger === 'imperative') {
this
.contentArea
.nativeElement
.scrollTop = 0;
} if (trigger === 'popstate') {
this
.contentArea
.nativeElement
.scrollTop = positions[idToRestore];
}
});The final touch is to queue the work correctly with the navigation cycle and the lifecycle’s of the activated route component. We want to scroll after our dynamic view is ready, which would be denoted by the Component AfterViewInit lifecycle hook.
To correctly queue the work we can use the RxJS operator observeOn and pass in the RxJS asyncScheduler just before the subscription block. This will let the browser event-loop defer execution of the subscription block to the proper time, in our case just after the AfterViewInit lifecycle hook:
import { asyncScheduler } from 'rxjs';
import { observeOn } from 'rxjs/operators';observeOn(asyncScheduler),You can learn more about the observeOn operator and the asyncScheduler in Alexander Poshtaruk’s article RxJS: applying asyncScheduler as an argument vs with observeOn operator.




