Saving Temporary Form-Data To The SessionStorage API In Angular 9.1.9
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
vsLocalStorage
- Both theSessionStorage
API and theLocalStorage
API implement the browser's "Web Storage" API. The difference is that theLocalStorage
data is shared across all instances of the given domain; and, theLocalStorage
data persists much more indefinitely. TheSessionStorage
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 explicitExpires
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:
- The user starts entering data.
- We store that unsaved data in the
SessionStorage
API. - If the user refreshes, we repopulate the form using the
SessionStorage
API. - The user submits the form.
- 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:
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 theLocalStorage
API; or, it could be something likeIndexedDB
; 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 aPromise
.I wanted to try and keep the
TemporaryStorageService
itself simple. So, instead of adding a bunch oftry/catch
blocks or checks for thewindow.sessionStorage
feature, I created two wrappers: one for theSessionStorage
API and one for an in-memory implementation. Both of these wrappers implement aStorageWrapper
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 theSessionStorage
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 theSessionStorage
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 asetTimeout()
.I don't want the aforementioned
setTimeout()
to trigger change-detection since there is no view-model that will change in reaction to thesetTimeout()
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 theTemporaryStorageService
never reads from the actualSessionStorage
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!
Want to use code from this post? Check out the license.
Reader Comments
Hi, thanks for the write up.
I had to do similar thing and stumbled upon this article https://netbasal.com/effortless-web-storage-persistence-in-angular-forms-cfa81ad23e30 which was very helpful.
@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
@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 theuseClass
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.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?
@Rmrz,
Good question. So, the
form.valueChanges
property is an Observable exposed by the Form itself (which I think implements theFormGroupDirective
or something under the hood). So, I'm taking that Observable and then I'm using theEventEmitter
on my component as the subscriber to theform.valueChanges
event.Essentially, I'm using a shorter version of this:
I can to do this because the
EventEmitter
object in Angular extends theSubject
class from RxJS. In fact, if you look at the source code, the.emit()
function inEventEmitter
just turns around and calls the.next()
method on theSubject
-- https://github.com/angular/angular/blob/10.0.3/packages/core/src/event_emitter.ts#L104I hope that helps a bit :D