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

Saving Temporary Form-Data To The SessionStorage API In Angular 9.1.9

By Ben Nadel on

I'm sure we've all been in one of those situations where we're filling out a big web-based form; then, we get some sort of an error page; so, we hit the browser's Back Button only to find-out that our form is now completely blank. Hulk smash! Some applications do us the courtesy of storing unsaved form data in a temporary storage location. I've never actually implemented anything like this. As such, I thought it would be a fun code-kata to try and save temporary / unsaved form-data to the SessionStorage API in an Angular 9.1.9 application.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The goal of persisting unsaved form-data to something outside of a component's local view-model is to protect the user experience (UX) from both the user (and their wonky behaviors) and from us, the developers. This way, if the user does something accidentally; or, our code does something in error; there's a good chance that the user's data can be kept in-tact.

To implement this quasi-persistent caching of form-data, I've opted to use the SessionStorage API. I like the idea of the SessionStorage API because it automatically clears itself when the user closes the current browser tab. As such, we don't really have to "clean up" after ourselves since the browser is going to do it for us. The SessionStorage API is also very simple, which means that our code doesn't have to get too complex.

SessionStorage vs LocalStorage - Both the SessionStorage API and the LocalStorage API implement the browser's "Web Storage" API. The difference is that the LocalStorage data is shared across all instances of the given domain; and, the LocalStorage data persists much more indefinitely. The SessionStorage is, therefore, much more transient in nature.

SessionStorage vs Session Cookies - Don't get confused between the two. A "Session Cookie" is a cookie entry that has no explicit Expires values. This means that the browser will clear the given cookie once the browser is shut-down.

That said, just because the SessionStorage API is simple, it doesn't mean that I can't have a little fun with the exploration. In fact, the Angular 9 code that I've come up with is more complicated than it needs to be; but, that's because I'm having fun with the layers of abstraction and the separation of concerns.

In fact, my App component, which is where the Form exists, doesn't even reference the SessionStorage API. Instead, it references a TemporaryStorageService class, which is a service that encapsulates a wrapper that further encapsulates the SessionStorage API.

To see this in action, let's start at the HTML / Angular view-template layer and then work our way down to the SessionStorage API. To set the context, I've created a small App component that composes a list of Friends. The "New Friend Form" is the Form for which we are going to temporarily cache the user's unsaved data.

This form uses Angular's powerful two-way data-binding to effortlessly synchronize the component's view-model with the view-template using ngModel:

<!--
	CAUTION: Note that we are binding to the (valueChanges) event on the Form so that we
	can react to any changes in any of the Controls in this Form. This event is NOT A
	NATIVELY-EXPOSED output on the template-driven NgForm Directive. As such, we are
	using a custom directive to expose the underlying (valueChanges) event. And, whenever
	the (valueChanges) event fires, we're using that as the trigger to persist the form
	view-model to the temporary storage.
-->
<form
	(submit)="processNewFriend()"
	(valueChanges)="saveToTemporaryStorage()">

	<div class="entry">
		<label>Name:</label>
		<input type="text" name="name" [(ngModel)]="formData.name" />
	</div>
	<div class="entry">
		<label>Nickname:</label>
		<input type="text" name="nickname" [(ngModel)]="formData.nickname" />
	</div>
	<div class="entry">
		<label>Description:</label>
		<textarea name="description" [(ngModel)]="formData.description"></textarea>
	</div>
	<div class="actions">
		<button type="submit">
			Add New Friend
		</button>

		<a href="./index.html" class="refresh">
			Refresh Page
		</a>
	</div>
</form>

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

	<h2>
		Friends
	</h2>

	<ul>
		<li *ngFor="let friend of friends">
			{{ friend.name }}

			<ng-template [ngIf]="friend.nickname">
				( aka, {{ friend.nickname }} )
			</ng-template>
		</li>
	</ul>

</ng-template>

The desired workflow for this form in this particular demo is as follows:

  1. The user starts entering data.
  2. We store that unsaved data in the SessionStorage API.
  3. If the user refreshes, we repopulate the form using the SessionStorage API.
  4. The user submits the form.
  5. We clear the SessionStorage API (now that the data has been submitted).

When it comes to building Forms in Angular, I love using template-driven forms. For me, they have all the same flexibility of reactive-forms, but without all of the ceremony. That said, one thing that template-driven forms don't make effortless is the ability to listen for any changes across the form (at least, not without injecting the NgForm instance into the parent component).

At first, I just listened to the (input) event on the form element, which is an event that bubbles up in the DOM (Document Object Model) whenever the value of an input, select, or textarea changes. And, for this demo, that would have been sufficient. But, as I said before, I wanted to have some fun with this exploration.

As such, I wanted to expose the (valueChanges) event on the form element itself (using the underlying NgForm directive and Control). To do this, I created a tiny Directive that selects on form[valueChanges] and wires the underlying form.valueChanges Observable into an output event on the form element:

// Import the core angular services.
import { Directive } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { NgForm } from "@angular/forms";

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

@Directive({
	selector: "form[valueChanges]",
	outputs: [ "valueChangeEvents: valueChanges" ]
})
export class FormValueChangesDirective {

	public valueChangeEvents: EventEmitter<any>;

	// I initialize the form value-changes directive. The goal of this directive is to
	// expose the (valueChanges) event on the underlying NgForm object such that it can
	// be subscribed-to in a template-driven form.
	constructor( form: NgForm ) {

		this.valueChangeEvents = new EventEmitter();

		if ( form.valueChanges ) {

			// CAUTION: I don't THINK that I need to worry about unsubscribing from this
			// Observable since they will both exist for the same life-cycles. But, I'm
			// not very good at RxJS, so I am not 100% sure on this.
			form.valueChanges.subscribe( this.valueChangeEvents );

		}

	}

}

As you can see, this Angular Directive is doing almost nothing. It just injects the NgForm instance from the current form element and then pipes (so to speak) the form.valueChanges stream into the (valuesChanges) EventEmitter / output binding on this Directive. This is what allows me to use following event-binding in my HTML view-template:

<form (valueChanges)="saveToTemporaryStorage()">

So, whenever the Angular Form registers changes in any of its Controls, it invokes the saveToTemporaryStorage() method on our App component. This is where we persist the user's unsaved form data to the SessionStorage.

Let's take a look at the App component to see how this fits together. Remember, the App component uses the SessionStorage API via a TemporaryStorageService abstraction. It then uses this abstraction to both persist the unsaved form-data as well to repopulate the form upon refresh (or other means of re-rendering):

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

// Import the application components and services.
import { TemporaryStorageFacet } from "./temporary-storage.service";
import { TemporaryStorageService } from "./temporary-storage.service";

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

interface Friend {
	id: number;
	name: string;
	nickname: string;
	description: string;
}

// NOTE: I'm using a longer name for this Interface so as not to cause confusion with
// the native FormData interface that is provided to help AJAX form-submissions.
interface NewFriendFormData {
	name: string;
	nickname: string;
	description: string;
}

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

	public friends: Friend[];
	public formData: NewFriendFormData;

	private temporaryStorage: TemporaryStorageFacet;

	// I initialize the app component.
	constructor( temporaryStorageService: TemporaryStorageService ) {

		// The TemporaryStorageService is a glorified key-value store. And, for this
		// component, we are going to store all of the temporary form-data in a single
		// key. As such, we can make our lives easier by creating a "Facet" of the
		// temporary storage, which locks-in a key, allowing us to make subsequent calls
		// against the facet without providing a key.
		this.temporaryStorage = temporaryStorageService.forKey( "new_friend_form" );

		this.friends = [];
		this.formData = {
			name: "",
			nickname: "",
			description: ""
		};

	}

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

	// I get called once after the input bindings have been wired-up.
	public ngOnInit() : void {

		this.restoreFromTemporaryStorage();

	}


	// I process the new-friend form.
	public processNewFriend() : void {

		if ( ! this.formData.name ) {

			return;

		}

		this.friends.push({
			id: Date.now(),
			name: this.formData.name,
			nickname: this.formData.nickname,
			description: this.formData.description
		});

		// Reset the form's view-model.
		this.formData.name = "";
		this.formData.nickname = "";
		this.formData.description = "";

		// Now that we've processed the new-friend form, we can flush any temporarily-
		// cached form data from our temporary storage.
		this.temporaryStorage.remove();

	}


	// I attempt to load persisted data from our Facet of the TemporaryStorageService
	// into the current view-model of the form-data.
	public async restoreFromTemporaryStorage() : Promise<void> {

		var cachedFormData = await this.temporaryStorage.get<NewFriendFormData>();

		if ( cachedFormData ) {

			Object.assign( this.formData, cachedFormData );

		}

	}


	// I save the current form-data view-model to the temporary storage.
	public saveToTemporaryStorage() : void {

		// NOTE: If I wanted to save a tiny bit of memory, I could check to see if any of
		// the form-data was actually populated before I persisted it to the temporary
		// storage. But, seeing as I would generally remove this data during the
		// ngOnDestroy() life-cycle event, there's really no need to make the code more
		// "clever" than it has to be.
		this.temporaryStorage.set( this.formData );

	}

}

As you can see, we're using the TemporaryStorageService (and TemporaryStorageFacet) in three places:

  • Repopulating the form in ngOnInit().
  • Persisting the unsaved form data in saveToTemporaryStorage().
  • Clearing the temporary storage in processNewFriend().

If you think of the TemporaryStorageService class as being a glorified key-value store, you can think of the TemporaryStorageFacet class as being an instance of the storage class with a hard-coded key. The TemporaryStorageFacet just simplifies some of the consumption by removing the need for us to repeat the key in all three of the above interactions.

If we run this Angular application in the browser, populate the form, and then refresh the browser, you'll see that the unsaved data is persisted across renderings:

Unsaved form data being saved-to and then read-from the SessionStorage API in an Angular 9.1.9 app.

As you can see, by persisted the unsaved form data in the SessionStroage API, we've potentially enhanced the user experience (UX)!

ASIDE: Again, the SessionStorage is tab-specific. As such, if I were to open this URL up in another tab, the form would be empty.

So far, we haven't really done anything too exciting. This App component represents a standard form-based experience and does a little extra data-management to persist and repopulate the form. The more interesting details are in the TemporaryStorageService class itself.

But, before we look at the TemporaryStorageService class, let me express some of my goals:

  • Although I'm using SessionStorage for this demo, I wanted to try and create an abstraction that could allow for different types of storage. That could be the LocalStorage API; or, it could be something like IndexedDB; or, even an AJAX call to the server. As such, I didn't want to depend on the get method being synchronous. As such, the .get() method returns a Promise.

  • I wanted to try and keep the TemporaryStorageService itself simple. So, instead of adding a bunch of try/catch blocks or checks for the window.sessionStorage feature, I created two wrappers: one for the SessionStorage API and one for an in-memory implementation. Both of these wrappers implement a StorageWrapper Interface. This way, I can use either of them under th hood without having to worry about which technology is actually in play.

  • I wanted to debounce calls to SessionStorage. For all the simplicity of the SessionStorage API, one of the drawbacks is that it is a synchronous API. This means that, given some amount of data, there's a chance that serializing values and writing them to the SessionStorage API may lock-up the main rendering thread and cause some "jank" in the user experience (UX). Personally, I've never actually experience this with the Web Storage APIs; but, theoretically, "it's a thing". As such, I'm flushing the data inside of a setTimeout().

  • I don't want the aforementioned setTimeout() to trigger change-detection since there is no view-model that will change in reaction to the setTimeout() callback. As such, I'm going to wire-it-up outside the NgZone.

  • As another attempt to side-side the synchronous nature of the SessionStorage API, I'm going to use an in-memory write-through cache. This way, reading from the TemporaryStorageService never reads from the actual SessionStorage API (at least, not after load-time).

With all that said, here's the code that I came up with. Remember, this is more complicated than it needed to be for this demo; but, I wanted to have some fun with it, noodle on some APIs, and think about the separation of concerns:

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

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

interface StorageWrapper {
	// NOTE: Even though the current storage implementations for StorageWrapper both
	// provide SYNCHRONOUS APIs, I'm forcing the GET request to be an ASYNCHRONOUS API in
	// order to future-proof it against other implementations that use technologies like
	// IndexedDB or remote AJAX requests and cannot be synchronous.
	get<T>( key: string ) : Promise<T | null>;
	remove( key: string ) : void;
	set( key: string, value: any ) : void;
}

interface StorageCache {
	[ key: string ]: any;
}

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

	private storage: StorageWrapper;

	// I initialize the temporary storage service, which can act as a sort of "flash
	// memory", persisting data across page-refreshes (if the underlying technologies
	// are available).
	constructor( zone: NgZone ) {

		// Since the type of storage may vary from browser to browser, I'm wrapping the
		// different technologies in abstractions that all expose the same, simple API.
		this.storage = ( window.sessionStorage )
			? new SessionStorageWrapper( zone )
			: new InMemoryWrapper()
		;

	}

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

	// I provide a Facet of the temporary storage associated with the given key. A facet
	// provides a simplified interaction model for a calling context that wants to get
	// a slice of the temporary storage for a given UI, interact with it briefly, and
	// then clear it when its done using it.
	public forKey( key: string ) : TemporaryStorageFacet {

		return( new TemporaryStorageFacet( key, this.storage ) );

	}


	// I get the data associated with the given key.
	public get<T>( key: string ) : Promise<T | null> {

		return( this.storage.get<T>( key ) );

	}


	// I remove the data associated with the given key.
	public remove( key: string ) : void {

		this.storage.remove( key );

	}


	// I store the given value with the given key.
	public set( key: string, value: any ) : void {

		this.storage.set( key, value );

	}

}

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

export class TemporaryStorageFacet {

	private key: string;
	private storage: StorageWrapper;

	// I initialize the temporary storage facet that is locked-in to the given key.
	constructor( key: string, storage: StorageWrapper ) {

		this.key = key;
		this.storage = storage;

	}

	// ---
	// PUBLC METHODS.
	// ---

	// I get the data associated with the locked-in key; or, null if the data has not
	// been defined (set).
	public get<T>() : Promise<T | null> {

		return( this.storage.get<T>( this.key ) );

	}


	// I remove the data associated with the locked-in key.
	public remove() : void {

		this.storage.remove( this.key );

	}


	// I store the given value with the locked-in key.
	public set( value: any ) : void {

		this.storage.set( this.key, value );

	}

}

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

class SessionStorageWrapper implements StorageWrapper {

	private debounceDuration: number;
	private cache: StorageCache;
	private storageKey: string;
	private timerID: number;
	private zone: NgZone;

	// I initialize an SessionStorage API implementation of the storage wrapper.
	constructor( zone: NgZone ) {

		this.zone = zone;
		this.cache = Object.create( null );
		this.storageKey = "temp_session_storage";

		// The Debounce duration is the trade-off between processing and consistency.
		// Since the SessionStorage API is synchronous, we don't necessarily want to
		// write to it whenever the cache is updated. Instead, we'll use a timerID to reach
		// a moment of inactivity; and then, serialize and persist the data.
		this.debounceDuration = 1000; // 1-second.
		this.timerID = 0;

		// NOTE: Since the SessionStorage API is browser-TAB-specific, we can read the
		// persisted data into memory on load; and, then use the in-memory cache as a
		// buffer in order to cut-down on synchronous processing.
		this.loadFromCache();

	}

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

	// I get the value associated with the given key; or null if the key is undefined.
	public async get<T>( key: string ) : Promise<T | null> {

		return( <T>this.cache[ key ] ?? null );

	}


	// I remove any value associated with the given key.
	public remove( key: string ) : void {

		if ( key in this.cache ) {

			delete( this.cache[ key ] );
			this.persistToCache();

		}

	}


	// I store the given value with the given key.
	public set( key: string, value: any ) : void {

		this.cache[ key ] = value;
		this.persistToCache();

	}

	// ---
	// PRIVATE METHOD.
	// ---

	// I debounce invocations of the given callback outside of the Angular zone.
	private debounceOutsideNgZone( callback: Function ) : void {

		this.zone.runOutsideAngular(
			() => {

				clearTimeout( this.timerID );

				this.timerID = setTimeout(
					( )=> {

						this.timerID = 0;
						callback();

					},
					this.debounceDuration
				);

			}
		);

	}


	// I load the SessionStorage payload into the internal cache so that we don't need
	// to read from the SessionStorage whenever the .get() method is called.
	private loadFromCache() : void {

		try {

			var serializedCache = sessionStorage.getItem( this.storageKey );

			if ( serializedCache ) {

				Object.assign( this.cache, <StorageCache>JSON.parse( serializedCache ) );

			}

		} catch ( error ) {

			console.warn( "SessionStorageWrapper was unable to read from SessionStorage API." );
			console.error( error );

		}

	}


	// I serialize and persist the cache to the SessionStorage, using debouncing.
	private persistToCache() : void {

		// Since we don't want a change-detection digest to run as part of our internal
		// timer (we have no view-models that will change in response to this action),
		// let's wire-it-up outside of the core Angular Zone.
		this.debounceOutsideNgZone(
			() => {

				console.warn( "Flushing to SessionStorage API." );
				// Even if SessionStorage exists (which is why this Class was
				// instantiated), interacting with it may still lead to runtime errors.
				// --
				// From MDN: If localStorage does exist, there is still no guarantee that
				// localStorage is actually available, as various browsers offer settings
				// that disable localStorage. So a browser may support localStorage, but
				// not make it available to the scripts on the page. For example, Safari
				// browser in Private Browsing mode gives us an empty localStorage object
				// with a quota of zero, effectively making it unusable. Conversely, we
				// might get a legitimate QuotaExceededError, which means that we've used
				// up all available storage space, but storage is actually available.
				try {

					sessionStorage.setItem( this.storageKey, JSON.stringify( this.cache ) );

				} catch ( error ) {
					
					console.warn( "SessionStorageWrapper was unable to write to SessionStorage API." );
					console.error( error );

				}

			}
		);

	}

}

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

class InMemoryWrapper implements StorageWrapper {

	private cache: StorageCache;

	// I initialize an in-memory implementation of the storage wrapper.
	constructor() {

		this.cache = Object.create( null );

	}

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

	// I get the value associated with the given key; or null if the key is undefined.
	public async get<T>( key: string ) : Promise<T | null> {

		return( <T>this.cache[ key ] ?? null );

	}


	// I remove any value associated with the given key.
	public remove( key: string ) : void {

		delete( this.cache[ key ] );

	}


	// I store the given value with the given key.
	public set( key: string, value: any ) : void {

		this.cache[ key ] = value;

	}

}

The individual parts of this module are small - there's barely a method that has more than a few lines of code. It's the composition of the various classes that makes this fun exploration.

Right now, the TemporaryStorageService is explicitly checking for the window.sessionStorage API in its own constructor. An interesting evolution of this approach would be to have the StorageWrapper implementation be injected as a behavior such that the TemporaryStorageService wouldn't even need to know which version was being instantiated. But, I'll defer that to a follow-up post on overriding services in the @Injectable() decorator.

One additional fun tidbit in this exploration is that it uses the Nullish Coalescing operator that is now available in TypeScript 3.7:

return( <T>this.cache[ key ] ?? null );

This operator - ?? - is much like the Elvis Operator in Lucee CFML / ColdFusion; and provides "fall back values" when the left-hand operand is null or undefined. What a lovely convenience!

I've never actually used anything like this in a production Angular application. But, I think - as users - we can all relate to the pain of losing form data because of an accidental refresh or ill-conceived application workflows. As such, I do think there's value in storing form data in some sort of temporary storage area, like the SessionStorage API.

Epilogue on the Safety Of Using the Browser's Storage APIs

While I was reading up on the SessionStorage API, I came across a thought provoking post by Randall Degges titled, Please Stop Using Local Storage, in which he makes a case of how the LocalStorage API (which is a sibling of the SessionStorage API) is a bad place to store secure information (an approach that has been popularized in part by the proliferation of JWTs - JSON Web Tokens).

I don't necessarily agree with what he saying; but, his post plus the comments to the post make for an excellent read about web security. I highly recommend it!



Reader Comments

@Hassam,

I actually just saw Netanel's article in the ng-newsletter this morning. I am excited to read it after work to see what his approach is like. I gave it a cursory glance, and saw him using RxJS, which I don't use very much. Looking forward to it :D

Reply to this Comment

@All,

I wanted to follow-up this post with another one on how something like the TemporaryStorageService could be made more dynamic:

www.bennadel.com/blog/3836-using-abstract-classes-as-dependency-injection-tokens-with-providedin-semantics-in-angular-9-1-9.htm

In this follow-up post, I'm using the @Injectable() decorator to define an abstract class as the dependency-injection token; and then using the useClass property - which I didn't even know existed on the @Injectable() decorator - as a means to provide to a default, concrete implementation of the abstract class.

Reply to this Comment

Hi. I got a question. Can you explain on this line
form.valueChanges.subscribe( this.valueChangeEvents );

AFAIK, event emitter as emit method. in your case, you pass a reference of eventemitter. How does it work without calling emit method?

Reply to this Comment

@Rmrz,

Good question. So, the form.valueChanges property is an Observable exposed by the Form itself (which I think implements the FormGroupDirective or something under the hood). So, I'm taking that Observable and then I'm using the EventEmitter on my component as the subscriber to the form.valueChanges event.

Essentially, I'm using a shorter version of this:

form.valueChanges.subscribe(
	( value ) => {
	
		this.valueChangeEvents.emit( value );
	
	}
);

I can to do this because the EventEmitter object in Angular extends the Subject class from RxJS. In fact, if you look at the source code, the .emit() function in EventEmitter just turns around and calls the .next() method on the Subject -- https://github.com/angular/angular/blob/10.0.3/packages/core/src/event_emitter.ts#L104

I hope that helps a bit :D

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
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.