Using Local Storage with Angular Services


With HTML5 Local Storage, there's a lot of potential to bring a bit better user experience to an application. Here are a few use-cases for local storage:

  • Long term storage - there's no expiration on local storage data.
  • Fallback for API calls - show most recently obtained data and fail gracefully
  • User is offline - used in conjunction with HTML5 Cache Manifest can make your app nearly fully-functional offline!

Usage Examples

I am a big fan of the AngularJS framework, so I'll be showing examples within the context of an Angular Service. However, the way you interact with local storage is the same regardless of frameworks.

We'll be looking at a service that interacts with an API for widgets. We want to incorporate our local storage logic into the app in such a way that the rest of the app only has to know about the widget service, and nothing about local storage. For the sake of brevity, here are some assumptions:

  • Another service, named "appConnectivityService", is used to check if we are currently online.
  • Our widget service, named "widgetService", exposes the following API:
    • get( [id] ) - if no id supplied, entire list is returned
    • add( widget )
    • update( widget )
    • delete( widget )
  • For brevity, we'll just use the $http angular service instead of $resource.

Set up local storage widget service

First thing we need to do is to set up the local storage service for our widgets:

(function( app ) {
	app.factory('localStorageWidgetService', ['$window', function ($window) {
    	//determine if local storage is supported
    	var _supportsLocalStorage;
        
        try {
        	_supportsLocalStorage = ('localStorage' in $window && $window.localStorage instanceof Storage);
        } catch (err) {
        	_supportsLocalStorage = false;
        }
    
    	//create some keys to use when accessing localStorage
    	var lsKeys = {
    		widgets: 'my.app.widgets',
            //If a user's offline but makes a change, may want to put in localStorage to persist to back end later.  Exercise for reader ;-)
        	changes: 'my.app.widgets.changes'
    	};
    
    	//todo: build methods for service
    
    	//declare our service    
    	var service = {};
    
    	//return the service
    	return service;
    }]);
})( angular.module('widgets') );

The first thing to notice is that we've created some "keys" (var lsKeys...). This is because local storage works with key/value pairs. In order to get or set a value, you must provide a key to use. The API that local storage provides is pretty simple. We'll be looking at these methods:

  • getItem (key) - returns the string value for the key or undefined
  • setItem (key, [string]value) - setter for the key
  • removeItem (key) - removes the entry

Notice that you can only get/set string values with local storage. Thankfully, there's JSON.parse and JSON.stringify()!


Logic for retrieving widgets
//private function for parsing widgets
function parseWidgets(){
	if(!_supportsLocalStorage) {
    	return [];
    }
    
    var strWidgets = $window.localStorage.getItem(lsKeys.widgets);
    //will be null if not found
    if(!!strWidgets) {
    	var widgets = JSON.parse(strWidgets);
        
        //for consistency, always return an array
        if(!Array.isArray(widgets)) {
        	widgets = [widgets];
        }
        
        return widgets;
    }
    
    return [];
}

function getWidgets(id) {
	//get full list of widgets
	var widgets = parseWidgets();
    //if we have an id, return the single widget with that id if it's in local storage
    if((id + '').length > 0) {
    	widgets = widgets.filter(function(w) {
        	return w.id === id;
        });
        
        if(widgets.length > 0) {
        	return widgets[0];
        }
        
        return null;
    }
    //else, return the full list
    return widgets;
}

...
//add functionality to the exposed service
service.getById = function(id) {
	if((id+'').length > 0) {
		return getWidgets(id);
    }
    return null;
};

Notice in the service how we have a private method for parsing the widget string. This is so we don't have to repeat that logic in other methods.

Also notice that we don't have an exposed way to get all of the widgets. That's coming next :)


Logic for adding widgets
//private method for saving widgets to localStorage
function saveWidgets(widgets) {
	if(!_supportsLocalStorage) {
    	return;
    }
    //assumption is that only objects/arrays will be passed in here.
    $window.localStorage.setItem(keys.widgets, JSON.stringify(widgets));
}

function addWidget(widget) {
	//get all the widgets, because we basically have to overwrite the entire value in localStorage on each update
    var widgets = parseWidgets();
    //check to see if we're updating an existing widget, or adding a new one
    var match = widgets.filter(function(w) {
    	return w.id === widget.id;
    });
    
    if(match.length > 0) {
    	widgets[widgets.indexOf(match[0])] = widget;
    } else {
    	widgets.push(widget);
    }
    
    saveWidgets(widgets);
};
...
//expose functionality to service
service.add = addWidget;

//use Object.defineProperty to create a property with custom get/set functions for dealing with all widgets at once
Object.defineProperty(service, 'widgets', {
	get: function() {
    	return getWidgets();
    },
    set: function(widgets) {
    	saveWidgets(widgets);
    }
});

Notice the use of Object.defineProperty to add custom get/set methods to the property. This will allow us some syntactic sweetness later on when we try to get/set all of the widgets in local storage.

shameless plug time!

For more info on Object.defineProperty, check out another tutorial I wrote here.


Finally, add in logic for removing one or all widgets
//remove a single widget
function removeWidget(widgetId) {
	var widgets = parseWidgets();
    
    var widget = widgets.filter(function(w) {
    	return w.id === widgetId;
    });
    
    if(widget.length > 0) {
    	//remove the deleted widget
		widgets.splice(widgets.indexOf(widget[0]), 1);
        
        saveWidgets(widgets);
    }
}

...
//add functionality to service
service.remove = removeWidget;

//can add clear directly to the service...
//may not be used by main widgetService, but would be good for if we were setting time limits for how long data can be accessed offline, etc...
service.clear = function() {
	if(!_supportsLocalStorage) {
    	return;
    }
   
   $window.localStorage.removeItem(lsKeys.widgets);
};

Wiring it up in the main widget Service

First, we need to inject the localStorageWidgetService we just created into our main widget service:

(function( app ) {
	app.factory('widgetService', ['$http', '$q', 'appConnectivitySvc', 'localStorageWidgetService', function($http, $q, appConnSvc, lsWidgetSvc) {
    	//$q is for creating deferreds and promises
        //appConnSvc is to tell if we're online or not
    	//lsWidgetSvc is for interacting with localStorage
        
        //endpoint for our API
        var endpoint = 'http://www.myServiceEndpoint.com/widgets/';
        
        var service = {};
        
        //todo: populate service object
        
        return service;
        
    }]);
})( angular.module('widgets') );

Now that we have reference to our new service, let's go through and add in the functionality 1x1 like we did with creating the local storage functionality.


Getting widgets

function getWidgetById(id) {
	var defer = $q.defer();
    
    //convert to string
    id = id + '';
    
    if(id.length === 0) {
    	defer.resolve(null);
    } else if(appConnSvc.isConnected) {
    	$http.get(endpoint + id)
        	.success(function gotWidget(data) {
            	//persist to localStorage
                lsWidgetSvc.add(data);
                //fulfill the promise
                defer.resolve(data);
            })
            .error(function noGetWidget(err) {
            	//even if no widgets, we've gracefully handled error and offline state.  If there are widgets, our UI populates with data :)
            	defer.resolve(lsWidgetSvc.getById(id);
            });
    } else {
    	defer.resolve(lsWidgetSvc.getById(id));
    }
    
    return defer.promise;
}

function getWidgets() {
	var defer = $q.defer;
    
    if(appConnSvc.isConnected) {
    	$http.get(endpoint)
        	.success(function gotWidgets(data) {
            	//handy set syntax due to custom property get/set functions :)
            	lsWidgetSvc.widgets = data;
                defer.resolve(data);
            })
            .error(function noGetWidgets(err) {
            	//handy get syntax
            	defer.resolve(lsWidgetSvc.widgets);
            });
    } else {
    	//and more handy get syntax
        defer.resolve(lsWidgetSvc.widgets);
    }
    
    return defer.promise;
}

...
//add functionality to the service
service.get = function(id) {
	if((id+'').length === 0) {
    	return getWidgets();
    } else {
    	return getWidgetById(id);
    }
};

Add or update a widget
function addUpdateWidget(w) {
	var defer = $q.defer;
    
    if(!w) {
    	defer.reject('no widget provided');
    } else if (appConnSvc.isConnected) {
    	//see if we're adding or updating
    	var adding = (w.id + '').length === 0;
    	var httpMethod = adding ? 'post' : 'put';
        
    	$http[httpMethod](endpoint, w)
        	.success(function addedWidget(data) {
            	//add to localStorage - doesn't matter if it's an insert or update b/c .add takes care of either.
                lsWidgetSvc.add(w);
                defer.resolve(data);
            })
            .error(function noAddWidget(err) {
            	//nothing to do in localStorage
            	defer.reject(err);
            });
    } else {
    	//TODO: Save in localStorage in a way it can be picked up later and pushed to back end when connectivity restored.  Exercise for reader ;-)
        
        defer.reject('not currently connected');
    }
    
    return defer.promise;
}

...
//add functionality to service
service.add = service.update = addUpdateWidget;


And finally, add in functionality to delete a widget
	function deleteWidget(id) {
    	var defer = $q.defer;
    
    	id = id+'';
        
        if(id.length === 0) {
        	defer.reject('no id provided');
        } else if (appConnSvc.isConnected) {
        	$http.delete(endpoint + id)
            	.success(function deletedWidget(data) {
                	//remove from localStorage
                    lsWidgetSvc.remove(id);
                    defer.resolve(data);
                })
                .error function noDeleteWidget(err) {
                	defer.reject(err);
                });
        } else {
        	//TODO: add functionality to capture deletes in localStorage and then persist them when connectivity restored.  Exercise for reader ;-)
            
            defer.reject('not currently connected');
        }
        
        return defer.promise;
    }

Well, there you have it! Now our widgetService is pretty seamlessly utilizing our localStorageWidgetService, and our app's other services and controllers only need to know how to interact with the main widgetService!

Sometime soon, we may look at how to unit test services that interact with localStorage... that is a fun one ;-)