Encapsulating LocalStorage Access In AngularJS
The other day, the following JavaScript error starting showing up in one of our logs, "QUOTA_EXCEEDED_ERR: DOM Exception 22: An attempt was made to add something to storage that exceeded the quota." After a bit of debugging, it turned out that this was the error that Safari throws, when running in Private Mode, if you try to store data using the localStorage API. While this error was easy to fix (it was actually a bug in our code), it got me thinking about using localStorage in an AngularJS application; and, why there are various reasons that it would be best to encapsulate such an API.
Run this demo in my JavaScript Demos project on GitHub.
From a practical standpoint, encapsulating the localStorage API will shield your application against inconsistent support for localStorage. Granted, all modern browsers now support the localStorage API; however, it clearly isn't available in Safari under all circumstances. So, encapsulating the localStorage implementation means that your consuming components don't have to worry about such inconsistencies.
Aside from this Safari edge-case, I believe that encapsulating the localStorage API has other benefits. For starters, localStorage only deals with simple values. So, if you want to store an Object, you have to serialize it first. Conversely, if you want to retrieve a value, you have deserialize it before you use it. This is frustrating. Encapsulating the localStorage API means that you can provide an easier-to-consume interface that deals with values in their natural state.
Another benefit of encapsulation is that you can manage the lifecycle around the use of the localStorage cache. In addition to requiring serialized data, I have also been told (but have not personally tested) that localStorage is relatively slow. So, by encapsulating it, you can minimize the points-of-interaction during the application lifecycle. For example, you have the option to defer persistence until the application unloads.
Finally, by encapsulating the localStorage API, you will be implicitly creating an injectable object. And, any object - once injectable in an AngularJS application - is swappable, which means that integration testing, in relation to such an object, becomes easier.
To experiment with this in an AngularJS context, I've created a simple application which allows me to curate a list of friends. The friend data is persisted to the localStorage which means that it will be available across page reloads. The data will be managed through the Data Access Object (DAO), friendService. This DAO requires the "storage" service, which will encapsulate the localStorage:
Controller -> friendService -> storage -> localStorage.
The storage service provides an in-memory cache that only deals with localStorage at two points in the application lifecycle:
This requires more active memory consumption; but, memory is cheap. And it means that storage interactions, during the life of the application, are very fast.
CAUTION: The underlying assumption in the last statement is that copying objects in memory is faster that pushing them into localStorage, pulling them out of localStorage and, then, deserializing them. That said, this performance assumption is based primarily on hearsay and intuition, not measurements.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Encapsulating LocalStorage Access In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Encapsulating LocalStorage Access In AngularJS
</h1>
<h2>
You have {{ vm.friends.length }} friends!
</h2>
<!--
This list of friends will be persisted in localStorage and will be seen
across page-refresh actions.
-->
<ul>
<li ng-repeat="friend in vm.friends track by friend.id">
{{ friend.name }} ( <a ng-click="vm.removeFriend( friend )">delete</a> )
</li>
</ul>
<form ng-submit="vm.processForm()">
<input type="text" ng-model="vm.form.name" size="30" />
<input type="submit" value="Add Friend" />
</form>
<p>
<a ng-click="vm.logout()">Log out</a> ( <em>will kill local storage</em> ).
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.16.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function provideAppController( $scope, $window, friendService, storage ) {
var vm = this;
// I hold the collection of friends.
vm.friends = [];
// I hold the form data for use with ngModel.
vm.form = {
name: ""
};
loadRemoteData();
// Expose the public API.
vm.logout = logout;
vm.processForm = processForm;
vm.removeFriend = removeFriend;
// ---
// PUBLIC METHODS.
// ---
// When the user wants to log out of the application, we don't want
// the in-memory cache to persist to disk. As such, we need to explicitly
// disable the persistence before we bounce them out of the app.
function logout() {
storage.disablePersist();
$window.location.href = "./logout.htm";
}
// I process the new-friend form in an attempt to add a new friend.
function processForm() {
if ( ! vm.form.name ) {
return;
}
friendService
.addFriend( vm.form.name )
.then( loadRemoteData )
;
vm.form.name = "";
}
// I remove the given friend from the collection.
function removeFriend( friend ) {
// NOTE: Normally, I would optimistically remove the friend from the
// local collection; however, since I know that all of the data in
// this demo is client-side, I'm just going to reload the data as it
// will be loaded faster than the user can perceive.
friendService
.deleteFriend( friend.id )
.then( loadRemoteData )
;
}
// ---
// PRIVATE METHODS.
// ---
// I apply the remote data to the view-model.
function applyRemoteData( friends ) {
vm.friends = friends;
}
// I load the remote data for use in the view-model.
function loadRemoteData() {
friendService
.getFriends()
.then( applyRemoteData )
;
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a repository for friends. I use the storage service to persist data
// across page reloads.
angular.module( "Demo" ).factory(
"friendService",
function provideFriendService( $q, storage ) {
// Attempt to pull the friends out of storage.
// --
// NOTE: Using .extractItem() instead of .getItem() since we don't
// really need the friends item to remain in storage once we have pulled
// it into this service (which, for this demo, does it's own caching).
// We'll be repopulating the cache in the onBeforePersist() event below,
// anyway; so, this will cut down on memory usage.
var friends = ( storage.extractItem( "friends" ) || [] );
// Rather than trying to keep the service-data and the cache-data in
// constant sync, let's just hook into the persist event of the storage
// which will give us an opportunity to do just-in-time synchronization.
storage.onBeforePersist(
function handlePersist() {
storage.setItem( "friends", friends );
}
);
// Return the public API.
return({
addFriend: addFriend,
deleteFriend: deleteFriend,
getFriends: getFriends
});
// ---
// PUBLIC METHODS.
// ---
// I add a new friend with the given name. Returns a promise that resolves
// with the newly generated ID.
function addFriend( name ) {
var id = ( new Date() ).getTime();
friends.push({
id: id,
name: name
});
return( $q.when( id ) );
}
// I remove the friend with the given id. Returns a promise which resolves
// whether or not the friend actually existed.
function deleteFriend( id ) {
for ( var i = 0, length = friends.length ; i < length ; i++ ) {
if ( friends[ i ].id === id ) {
friends.splice( i, 1 );
break;
}
}
return( $q.when() );
}
// I get the entire collection of friends. Returns a promise.
function getFriends() {
// NOTE: We are using .copy() so that the internal cache can't be
// mutated through direct object references.
return( $q.when( angular.copy( friends ) ) );
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// This is just here to make sure that the storage component is loaded when the
// app is bootstrapped. This will cause the localStorage I/O overhead to be front-
// loaded in the app rather than caused by a particular user interaction (which
// the user is more likely to notice).
angular.module( "Demo" ).run(
function loadStorage( storage ) {
// ... just sit back and bask in the glory of dependency-injection.
}
);
// I am the localStorage key that will be used to persist data for this demo.
angular.module( "Demo" ).value( "storageKey", "angularjs_demo" );
// I provide a storage API that uses an in-memory data cache that is persisted
// to the localStorage at the limits of the application life-cycle.
angular.module( "Demo" ).factory(
"storage",
function provideStorage( $exceptionHandler, $window, storageKey ) {
// Try to load the initial payload from localStorage.
var items = loadData();
// I maintain a collection of callbacks that want to hook into the
// unload event of the in-memory cache. This will give the calling
// context a chance to update their relevant storage items before
// the data is persisted to localStorage.
var persistHooks = [];
// I determine if the cache should be persisted to localStorage when the
// application is unloaded.
var persistEnabled = true;
// During the application lifetime, we're going to be using in-memory
// data access (since localStorage I/O is relatively expensive and
// requires data to be serialized - two things we don't want during the
// user to "feel"). However, when the application unloads, we want to try
// to persist the in-memory cache to the localStorage.
$window.addEventListener( "beforeunload", persistData );
// Return the public API.
return({
clear: clear,
disablePersist: disablePersist,
enablePersist: enablePersist,
extractItem: extractItem,
getItem: getItem,
onBeforePersist: onBeforePersist,
removeItem: removeItem,
setItem: setItem
});
// ---
// PUBLIC METHODS.
// ---
// I clear the current item cache.
function clear() {
items = {};
}
// I disable the persisting of the cache to localStorage on unload.
function disablePersist() {
persistEnabled = false;
}
// I enable the persisting of the cache to localStorage on unload.
function enablePersist() {
persistEnabled = true;
}
// I remove the given key from the cache and return the value that was
// cached at that key; returns null if the key didn't exist.
function extractItem( key ) {
var value = getItem( key );
removeItem( key );
return( value );
}
// I return the item at the given key; returns null if not available.
function getItem( key ) {
key = normalizeKey( key );
// NOTE: We are using .copy() so that the internal cache can't be
// mutated through direct object references.
return( ( key in items ) ? angular.copy( items[ key ] ) : null );
}
// I add the given operator to persist hooks that will be invoked prior
// to unload-based persistence.
function onBeforePersist( operator ) {
persistHooks.push( operator );
}
// I remove the given key from the cache.
function removeItem( key ) {
key = normalizeKey( key );
delete( items[ key ] );
}
// I store the item at the given key.
function setItem( key, value ) {
key = normalizeKey( key );
// NOTE: We are using .copy() so that the internal cache can't be
// mutated through direct object references.
items[ key ] = angular.copy( value );
}
// ---
// PRIVATE METHODS.
// ---
// I attempt to load the cache from the localStorage interface. Once the
// data is loaded, it is deleted from localStorage.
function loadData() {
// There's a chance that the localStorage isn't available, even in
// modern browsers (looking at you, Safari, running in Private mode).
try {
if ( storageKey in $window.localStorage ) {
var data = $window.localStorage.getItem( storageKey );
$window.localStorage.removeItem( storageKey );
// NOTE: Using .extend() here as a safe-guard to ensure that
// the value we return is actually a hash, even if the data
// is corrupted.
return( angular.extend( {}, angular.fromJson( data ) ) );
}
} catch ( localStorageError ) {
$exceptionHandler( localStorageError );
}
// If we made it this far, something went wrong.
return( {} );
}
// I normalize the given cache key so that we never collide with any
// native object keys when looking up items.
function normalizeKey( key ) {
return( "storage_" + key );
}
// I attempt to persist the cache to the localStorage.
function persistData() {
// Before we persist the data, invoke all of the before-persist hook
// operators so that consuming services have one last chance to
// synchronize their local data with the storage data.
for ( var i = 0, length = persistHooks.length ; i < length ; i++ ) {
try {
persistHooks[ i ]();
} catch ( persistHookError ) {
$exceptionHandler( persistHookError );
}
}
// If persistence is disabled, skip the localStorage access.
if ( ! persistEnabled ) {
return;
}
// There's a chance that localStorage isn't available, even in modern
// browsers. And, even if it does exist, we may be attempting to store
// more data that we can based on per-domain quotas.
try {
$window.localStorage.setItem( storageKey, angular.toJson( items ) );
} catch ( localStorageError ) {
$exceptionHandler( localStorageError );
}
}
}
);
</script>
</body>
</html>
As you may have noticed, the storage service provides registration for a "before persist" event. This event hook allows other services to interact the storage system at the end of the application lifecycle (which is triggered by the window's beforeunload event). This is helpful because the other services, like friendService, may have their own internal cache and don't need to interact with the storage system at all times. The "beforeuplaod" event allows the consuming services to push data into the storage system before the storage system persists to disk.
I like the persist-event idea because it still hides the localStorage implementation. But, it gives the consuming services a bit more flexibility in how they interact with storage. And, there's nothing to say that you can't have multiple persist events during the life time of the app - I just happend to have a single event in my demo.
Anyway, just some thoughts on why it might be a good idea to encapsulate the localStorage API. If nothing else, it just means that your AngularJS application won't break when people try to use it in Safari's Private mode (for those of you that still use Safari).
Want to use code from this post? Check out the license.
Reader Comments
I like the idea of encapsulating the logic of LS, however I dont see the need to clear out LS on page load and rewrite again onUnload. If for some reason your browser crashes (or a new window with same page is opened) the data is gone.
I think persisting should be done at interaction-time (or close to). There is a well-supported storage-event that you could listen for in your service - which should trigger a re-read from LS.
What are your thoughts on going that route? Of cause there is a possible performance-problem since LS is sync - not worth it?
@Filip,
Part of it is probably just a little "premature optimization." Writing to localStorage requires serialization which seems like unnecessarily slow work to do during a user-interaction. Of course, we're talking about computers that do like billions of operations a second :D So, a little serialization probably doesn't hurt.
I'm not sure if I can come up an answer that doesn't just feel "emotional". That said, I do like the idea that this makes it easier to disable the flushing of data to the localStorage API if you don't want it to, such as with a "logout" request.
But, ultimately, it probably won't make much difference one way or the other.
Great article . So Local storage encapsulation (storage API) provides in memory access to data .
I have extracted the try-catch block at the end of the persistData() function into its own flush() function, and I exported it. Thus, I can update the localStorage when I need to, while also leaving the current flow intact.
I am not able to see the localStorage being set in the browser,tried displaying them using window.localStorage in firebug,why is that?