Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Brute-Force Refreshing View-Data In The Background In Angular 11.0.5

By Ben Nadel on

At InVision, my team - the team that works on the legacy platform - continues to shrink as more resources are dedicated to the new platform. Which means, I continually need to do more with less. As such, I'm always looking for ways to simplify my coding techniques in order to move more Product. Early on in my Angular journey, I used to perform more optimistic updates of the view-model in response to system events. But, those techniques are complicated and time-consuming. And, lately, I've started to rely more on brute-force re-fetching of data in the background in order to keep the UI (user interface) up-to-date. It's not a perfect solution; but, it allows me to get more work done (while passing-around less data). As such, I thought it would be worth sharing a demo in Angular 11.0.5.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The way I build my Angular views, there is generally some sort of "load data" method which goes out and fetches all of the data needed to render the current component / view. I usually call it loadRemoteData(). Part of the logic that is baked into this method is the idea that the view is placed into a "loading" state, generally represented with an isLoading flag:

// I put the view-model into a non-ready state and load the remote data.
private async loadRemoteData() : Promise<void> {

	this.isLoading = true; // Put the whole component into a LOADING state.
	this.messages = [];

	try {

		this.messages = await this.messageService.getMessages();
		this.isLoading = false; // Take the component out of the LOADING state.

	} catch ( error ) {

		console.warn( "Could not load messages." );
		console.error( error );

	}

}

Here, you can see that we set isLoading to true and then fetch the data. And, once the data comes back, we revert the isLoading flag to false. This flag is then used to drive a Ready / Not-Ready state within the View template:

<!-- BEGIN: Loading State. -->
<p *ngIf="isLoading">

	Loading messages....

</p>
<!-- END: Loading State. -->


<!-- BEGIN: Ready State. -->
<div *ngIf="( ! isLoading )">

	Data is ready to render....

</div>
<!-- END: Ready State. -->

You could probably clean this up by breaking the views down into "Smart Containers" and "Dumb Components"; but, again, my goal here is move more Product with a smaller team, not necessarily to create the most "clean" solution, academically speaking. As such, the structural directive ngIf works really well.

Since this loading state is clearly disruptive to the user experience (UX), we don't want to use it when responding to system events (such as other users acting on the same data-set). This is why I used to put so much effort into "optimistic updates" of the view-model.

But, what I realized is that I can still get most of the simplicity of the "loading state" workflow if I just run the data-fetch in the background without setting the isLoading flag to true. Then, I can quietly keep the data synchronized while minimizing disruption to the current view-state and user experience.

I now typically do this with a method called, loadRemoteDataInBackground(). Which does the same thing - mostly - as loadRemoteData(); except it doesn't mess with the isLoading flag:

// I load the remote data quietly in the background without changing the ready-state.
private async loadRemoteDataInBackground() : Promise<void> {

	try {

		this.messages = await this.messageService.getMessages();

	} catch ( error ) {

		console.warn( "Could not load messages in the background." );
		console.error( error );

	}

}

Now, at any point and for any reason, if I need to quietly fetch data for this view, I can just call the loadRemoteDataInBackground() method and, moments later, the view-model is updated and the template is synchronized seamlessly.

Of course, since AJAX requests are asynchronous and subject to variable network and server latency, the order in which they return is unpredictable. This can be problematic since processing AJAX responses out-of-order can cause data to "flash" on the screen and then be removed (as other AJAX responses arrive at the client).

I used to experiment with very complicated ways to "protect" an AJAX response so that old responses were ignored. But - as is always the case - with time brings wisdom; and, with wisdom brings simplicity. What I came to realize is that I could tack a "request ID" and then just ignore any response when the request ID was no longer "current":

// I load the remote data quietly in the background without changing the ready-state.
private async loadRemoteDataInBackground() : Promise<void> {

	// Because of network latency, requests may return out-of-order. To protect our
	// view-model, we're going to track the index of this request and then ignore any
	// response that returns in an unexpected order. Since the two "load methods" are
	// loading and populating the same view-model, we're going to track them using
	// the same incrementing ID.
	var dataLoadID = ++remoteDateLoadID;

	try {

		var response = await this.messageService.getMessages();

		// If this request has returned out-of-order, ignore it - defer to the newer
		// request to update the view-model.
		if ( dataLoadID !== remoteDateLoadID ) {

			console.warn( "Ignoring request that returned out of order.", dataLoadID, "vs", remoteDateLoadID );
			return;

		}

		this.messages = response;

	} catch ( error ) {

		console.warn( "Error loading data in the background." );
		console.error( error );

	}

}

Now, with this dataLoadID, I simply ignore any response that is going to be overwritten by a newer data fetch. And, since I use the same remoteDateLoadID variable to track requests across both the loadRemoteData() and loadRemoteDataInBackground() methods, it becomes a simple way to prevent the two methods from stepping on each other's toes.

ASIDE: You might be wondering why I don't use something like the RxJS switchMap() operator so that I can cancel older requests when newer requests are initiated. The answer is two-fold. First, I still build mostly in AngularJS (for work) and don't have RxJS in our build. Second, I'm just not that smart. I look at RxJS and it just confuses me. Promises make sense. I get Promises. I get async/await. So, I used the tools that I understand and know how to debug. Your mileage may vary.

With all that said, let's look at the demo. I've tried to keep this as simple as possible; but, complex enough to demonstrate the background data fetch. The demo has a list of messages that I can add-to and remove-from. The messages are stored in the LocalStorage API so that I can listen for storage events and synchronize across browser tabs.

When the App component loads, I do a full data fetch (isLoading=true) to gather the view data. Then, for all subsequent interactions - adding or deleting messages - I use the background data fetch:

// Import the core angular services.
import { Component } from "@angular/core";

// Import the application components and services.
import { Message } from "./message.service";
import { MessageService } from "./message.service";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// Since all of the AJAX fetching is asynchronous and parallel and beholden to random
// latency issues, there's a good chance that AJAX responses will return in an
// unpredictable order. To simplify the data handling, I'm tracking the index of the
// outbound request and then ignoring ones that show up out-of-order.
// --
// NOTE: There's probably some clever RxJS way to "switch map" this. But, I am not smart
// when it comes to reactive programming.
var remoteDateLoadID = 0;

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	templateUrl: "./app.component.html"
})
export class AppComponent {

	public form: {
		isProcessing: boolean;
		newMessage: string;
	};
	public isLoading: boolean;
	public messages: Message[];

	private messageService: MessageService;

	// I initialize the app component.
	constructor( messageService: MessageService ) {

		this.messageService = messageService;

		this.isLoading = true;
		this.messages = [];
		this.form = {
			isProcessing: false,
			newMessage: ""
		};

		this.init();

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I process the new message form.
	public async addNewMessage() : Promise<void> {

		if ( ! this.form.newMessage || this.form.isProcessing ) {

			return;

		}

		this.form.isProcessing = true;

		try {

			await this.messageService.addMessage( this.form.newMessage );
			this.form.newMessage = "";
			this.form.isProcessing = false;

		} catch ( error ) {

			console.warn( "Could not add new message." );
			console.error( error );

			this.form.isProcessing = false;
			return;

		}

		// Once the message has been persisted to the server, let's reload the list of
		// messages in the background. This way, we don't have to show the "loading"
		// state AND we don't have to optimistically render the new message into the
		// current view. This makes the UI _slightly less_ responsive; but, keeps the
		// code relatively simple.
		this.loadRemoteDataInBackground();

	}


	// I delete the given message.
	public async deleteMessage( message: Message ) : Promise<void> {

		// Optimistically remove the message from the local view-model.
		// --
		// NOTE: While I am not doing anything optimistic with the "adding" of new
		// messages, removing a message is relatively easy to do, so why not.
		this.messages = this.messages.filter(
			( activeMessage ) => {

				return( activeMessage !== message );

			}
		);

		try {

			// CAUTION: Since we are AWAITING the delete before triggering the background
			// data fetch (below), there's a chance that a parallel background refresh
			// pull may "flash" the data that we optimistically removed above. I don't
			// believe there is any way around this (at least not easily).
			await this.messageService.deleteMessage( message.id );

		} catch ( error ) {

			console.warn( "Could not delete message." );
			console.error( error );
			return;

		}

		// In this demo, we don't have any additional data that may have changed due to
		// the removal of the message (such as aggregates); but, to showcase the concept,
		// we MIGHT WANT to reload the data after the delete in order to get a more true
		// view-model. This would, of course, depend on your situation.
		this.loadRemoteDataInBackground();

	}


	// I am used by the ngForOf directive to track the given object identity by the "id"
	// property so that the DOM nodes are kept in-tact during data fetching.
	public trackByID( value: any ) : any {

		return( value.id );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I get called once to initialize the component state.
	private init() : void {

		this.loadRemoteData();

		// When we detect that the underlying messages store has changed, we want to
		// re-fetch the data in the background in order to synchronous across tabs.
		this.messageService.subscribe(
			() => {

				this.loadRemoteDataInBackground();

			}
		);

	}


	// I put the view-model into a non-ready state and load the remote data.
	private async loadRemoteData() : Promise<void> {

		// Because of network latency, requests may return out-of-order. To protect our
		// view-model, we're going to track the index of this request and then ignore any
		// response that returns in an unexpected order.
		var dataLoadID = ++remoteDateLoadID;

		this.isLoading = true;
		this.messages = [];

		try {

			console.log( "Fetching data in foreground.", dataLoadID );
			var response = await this.messageService.getMessages();

			// If this request has returned out-of-order, ignore it - defer to the newer
			// request to update the view-model. This way, we don't get "flashes" of
			// incorrect data in the UI as the AJAX responses are processed.
			if ( dataLoadID !== remoteDateLoadID ) {

				console.warn( "Ignoring request that returned out of order.", dataLoadID, "vs", remoteDateLoadID );
				return;

			}

			this.isLoading = false;
			this.messages = response;

		} catch ( error ) {

			console.warn( "Could not load messages." );
			console.error( error );

		}

	}


	// I load the remote data quietly in the background without changing the ready-state.
	private async loadRemoteDataInBackground() : Promise<void> {

		// Because of network latency, requests may return out-of-order. To protect our
		// view-model, we're going to track the index of this request and then ignore any
		// response that returns in an unexpected order. Since the two "load methods" are
		// loading and populating the same view-model, we're going to track them using
		// the same incrementing ID.
		var dataLoadID = ++remoteDateLoadID;

		try {

			console.log( "Fetching data in background.", dataLoadID );
			var response = await this.messageService.getMessages();

			// If this request has returned out-of-order, ignore it - defer to the newer
			// request to update the view-model.
			if ( dataLoadID !== remoteDateLoadID ) {

				console.warn( "Ignoring request that returned out of order.", dataLoadID, "vs", remoteDateLoadID );
				return;

			}

			this.messages = response;

		} catch ( error ) {

			console.warn( "Error loading data in the background." );
			console.error( error );

		}

	}

}

As you can see, I use the loadRemoteDataInBackground() method in three places:

  • After the user adds a message.
  • After the user deletes a message.
  • In response to storage events from the MessageService class.

This allows me to keep the view up-to-date with less effort (in terms of optimistic updates to the view-model) and with less disruption to the user experience (in terms of placing the UI into a loading / non-ready state).

Here's the view template for this component:

<!-- BEGIN: Loading State. -->
<p *ngIf="isLoading">
	Loading messages....
</p>
<!-- END: Loading State. -->


<!-- BEGIN: Ready State. -->
<div *ngIf="( ! isLoading )">

	<form (submit)="addNewMessage()">
		<input
			type="text"
			name="newMessage"
			[(ngModel)]="form.newMessage"
			size="30"
			autofocus
			autocomplete="off"
		/>
		<button type="submit" [disabled]="form.isProcessing">
			Add message
		</button>
	</form>

	<ng-template [ngIf]="messages.length">

		<h2>
			Messages
		</h2>

		<ul>
			<li *ngFor="let message of messages; trackBy: trackByID;">
				<p>
					{{ message.text }}
				</p>
				<button (click)="deleteMessage( message )">
					Delete
				</button>
			</li>
		</ul>

	</ng-template>

</div>
<!-- END: Ready State. -->

Now, if I run this Angular 11 demo in two browser tabs at the same time, you can see data being fetch and synchronized in the background:

Data being synchronized across browser tabs using Angular 11.

As you can see, as I add and remove data in one browser tab, the tab in the background is seamlessly updating to reflect the changes. This is the loadRemoteDataInBackground() in action.

Now, you may have noticed two things in the above GIF:

  1. There appears to be a flash of "old" data for a moment as I'm deleting records. There is - this is the clashing of the "optimistic delete" colliding with the actual network request to delete and then re-fetch data while other parallel requests are in-flight. This is a very hard problem to solve! Thankfully, it only surfaces in UIs that have a lot of high-volume interactions.

  2. The other tab isn't updating immediately even though I'm using LocalStorage and not actually making requests over the network. This is because I'm simulating network latency in my MessageSerivce class.

The network latency simulation is being performed with a random setTimeout() value. Here's my MessageService implementation:

// Import the core angular services.
import { Injectable } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

export interface Message {
	id: number;
	text: string;
}

@Injectable({
	providedIn: "root"
})
export class MessageService {

	private callbacks: Function[];
	private localStorageKey: string;

	// I initialize the service.
	constructor() {

		// To make the demo more exciting, I'm going to store the data in the
		// LocalStorage API so that I can synchronize data across different browser tabs
		// in order to showcase the "loading in background" concept more effectively.
		this.localStorageKey = "load-data-in-background-angular11";
		this.callbacks = [];

		this.watchStorage();

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I create a new message with the given text. Returns the ID of the new record.
	public async addMessage( text: string ) : Promise<number> {

		await this.simulateNetwork();

		var id = Date.now();
		var messages = this.loadFromDisk();

		messages.push({
			id: id,
			text: text
		});

		this.saveToDisk( messages );

		return( id );

	}


	// I delete the message with the given ID.
	public async deleteMessage( id: number ) : Promise<void> {

		await this.simulateNetwork();

		var messages = this.loadFromDisk().filter(
			( message ) => {

				return( message.id !== id );

			}
		);

		this.saveToDisk( messages );

	}


	// I get all of the current messages.
	public async getMessages() : Promise<Message[]> {

		await this.simulateNetwork();

		return( this.loadFromDisk() );

	}


	// I subscribe the given callback to changes in the messages collection.
	// --
	// CAUTION: The callback is only triggered when changes are made by other browser
	// tabs. It uses the "storage" event to see when the underlying data changed.
	public subscribe( callback: Function ) : void {

		this.callbacks.push( callback );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I load the messages collection out of LocalStorage.
	private loadFromDisk() : Message[] {

		var data = localStorage.getItem( this.localStorageKey );

		if ( ! data ) {

			return( [] );

		}

		// CAUTION: For the sake of simplicity, I'm just blindly trusting that I can
		// parse the storage value into a valid messages collection. In a production
		// environment, I MIGHT want to do some additional validation (maybe).
		return( JSON.parse( data ) as Message[] );

	}


	// I save the messages collection to LocalStorage.
	private saveToDisk( messages: Message[] ) : void {

		localStorage.setItem( this.localStorageKey, JSON.stringify( messages ) );

	}


	// I provide a random delay to simulate some network latency while we work with a
	// purely local data model.
	private simulateNetwork() : Promise<void> {

		var promise = new Promise<void>(
			( resolve ) => {

				setTimeout( resolve, ( 100 + ( Math.random() * 1000 ) ) );

			}
		);

		return( promise );

	}


	// I listen for storage events and alert subscribers to changes triggered by other
	// browser tabs.
	private watchStorage() : void {

		window.addEventListener(
			"storage",
			( event: StorageEvent ) => {

				if ( event.key === this.localStorageKey ) {

					// CAUTION: In a production environment, I'd wrap the individual
					// callback invocations in a TRY/CATCH so that I could catch errors
					// without disrupting the entire set of callbacks. But, for the sake
					// of simplicity, I'm just firing them with abandon!
					for ( var callback of this.callbacks ) {

						callback();

					}

				}

			}
		);

	}

}

Is brute-force loading data in the background a perfect solution? No. And, for some types of user interfaces (UIs) it's likely to be insufficient. But, in a large majority of UIs, this technique should be "good enough". And, given the time and the resources that I have, "good enough" is the right approach for me and my team.



Reader Comments

What has two thumbs and hopes you leave a comment? This Guy! (Ben Nadel).

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.