Easily persist state with JSONModel and localStorage
G’day.
A while ago I wrote a small but convenient extension for the good old JSON model, because I was looking for an easy way to persist data in between sessions in a UI5 application. My client was keen on functionality to save filter settings in a search screen with quite a lot of options and they’d love it if their last filters would just reload if the app opened.
See it live in this codepen
Here’s my train of thought:
- I need to persist data between sessions, my app will be closed completely.
- I know localStorage can be used to save data in the browser
- I know I can store things in the JSON model
- I know that the bindings use
getProperty
andsetProperty
internally
Hence my conclusion to simply extend the JSONModel and redefine setProperty
and getProperty
to first check the localStorage, the model + binding functionality itself will do all of the heavy lifting for me. Nifty? Yes. Easy? Also yes.
This is the bare minimum.
LocalStorage basics
localStorage can be used for key/value pair storage in the browser itself. Most modern browsers can store quite a lot of data. According to this Wikipedia article, the minimum is 5MB per host, but users have reported far higher values across different browsers.
Since it’s key/value pairs, we should add a check to see what we’re storing. If it’s a JSON object, it should be stringified going in, and parsed coming out for instance.
LocalStorage is not secure, so heads up
By popular request, an added word of warning here about the accessibility of the data stored in LocalStorage. Most browser based storage is not secure, so be careful with sensitive data. As pointed out in the comments, people with access to your laptop, and any code running on the same domain – this includes third party scripts or scripts loaded via CDN – will have access to your LocalStorage. So, only store data that is not meant to be confidential.
LocalstorageJSONModel constructor
constructor: function(data, aFieldsToRetain) {
this._fieldToRetain = aFieldsToRetain || [];
this._storage = Storage;
if (!(this._fieldToRetain instanceof Array))
throw new Error(
"Fields to retain for local storage needs to be an array"
);
//good old constructor call to set the data
JSONModel.prototype.constructor.call(this, data);
},
We need a way to tell the JSONModel which fields to store, so addition to the data object you see the fieldsToRetain
array. This will contain a list of properties that you’d like to be stored. After that, the normal constructor is called.
Storage
is the window.localStorage
object, I figure I’d abstract it out in case I want to switch to cookies, sessionStorage, or maybe something from Cordova / PhoneGap / Mobile services. You never know!
setProperty
setProperty: function(param, value) {
if (this._fieldToRetain.indexOf(param) >= 0) {
const toStore = typeof value === 'object' ? JSON.stringify(value) : value;
this._storage.setItem(this._getLocalStorageParamName(param), toStore);
}
JSONModel.prototype.setProperty.call(this, param, value);
},
_getLocalStorageParamName: function(param) {
return `myapp${param}`;
}
That’s all. _getLocalStorageParamName
is a convenience method to generate a more unique identifier. I use the logged in userID and the application name from the manifest myself, don’t want to run into trouble between different apps (launchpad, I’m looking at you).
All you see here is a call to see if the current parameter is in the list of parameters and if it is, pass it to localStorage.setItem
.
If the value is an object, it should be stringified, otherwise it won’t work, because javascript will just call toString
which means you’ll end up with [Object object]
and 1,2,3
instead of your object or array.
getProperty
getProperty: function(param) {
var prop = this._storage.getItem(this._getLocalStorageParamName(param)) ||
JSONModel.prototype.getProperty.call(this, param)
var value;
//JSON.parse can actually crash, so the try/catch block is justified
try {
value = JSON.parse(prop);
} catch (e) {
value = prop;
}
return value;
},
First, it tries to fetch the parameter from localStorage and if it comes up empty, it calls the standard function and fetches from the JSONModel.
Then, parse the results. JSON.parse
crashes when fed rubbish, so if that call fails it was not stringified and we’ll return it the way it was.
Usage
const fieldsToRetain = ["/name"];
let model = new LocalstorageJSONModel({
name: 'Jorg',
country: 'Australia'
}, fieldsToRetain);
This will remember the name
forever and reload it when requested. If someone enters Jane
in the name field, the next time the app is opened the name Jane
appears regardless of how it’s initialised.
Here’s that demo again in case you made it down here and now you want to see it run. Be sure to type in your name and reload the page.
See it live in this codepen
In conclusion
That’s it. Offline capabilities and semi persistent storage with minimal effort. The drawback here is that the localStorage is – to a degree – out of the developer’s control and can be cleared quite easily by the user so it should not be used to store anything serious or confidential.
Feel free to fancy it up to suit your needs obviously. My own has convenience methods to store and regenerate whole nested sap.ui.model.Filter
objects for instance since I seem to be using that a lot. Added bonus is that, without the fieldsToRetain
object it’s just a JSONModel, so I turned it into a library and use it for everything. If you want to get rid of it again, change the import in the definition and it’s like nothing ever happened.
What do you reckon?
Full model
LocalstorageJSONModel.js
sap.ui.define(["sap/ui/model/json/JSONModel", "./Storage"], function(
JSONModel,
Storage
) {
"use strict";
return JSONModel.extend("demo.LocalstorageJSONModel", {
_fieldToRetain: false,
/*
constructor still takes the data object, but also an array of fields
to remember.
*/
constructor: function(data, aFieldsToRetain) {
this._fieldToRetain = aFieldsToRetain || [];
this._storage = Storage;
if (!(this._fieldToRetain instanceof Array))
throw new Error(
"Fields to retain for local storage needs to be an array"
);
//good old constructor call to set the data
JSONModel.prototype.constructor.call(this, data);
},
//redefine the getProperty method. Before the JSON model loads it's own content, I want
//to see what's inside the localstorage, and I'd like to parse it if it's JSON
getProperty: function(param) {
var prop = this._storage.getItem(this._getLocalStorageParamName(param)) ||
JSONModel.prototype.getProperty.call(this, param)
var value;
//JSON.parse can actually crash, so the try/catch block is justified
try {
value = JSON.parse(prop);
} catch (e) {
value = prop;
}
return value;
},
//convenience method. sometimes you just want to clear all variables.
clearStorage: function() {
this._storage.clear();
},
//redefine setProperty to first check if the field is in the fields to retain.
//in that case, send it to localstorage as well as do the parent method.
setProperty: function(param, value) {
if (this._fieldToRetain.indexOf(param) >= 0) {
const toStore = typeof value === 'object' ? JSON.stringify(value) : value;
this._storage.setItem(this._getLocalStorageParamName(param), toStore);
}
JSONModel.prototype.setProperty.call(this, param, value);
},
//you should create unique identifiers. in productive apps this needs more consideration. i use
//app id from the manifest and user ID.
_getLocalStorageParamName: function(param) {
return `myapp${param}`;
}
});
});
Storage.js
sap.ui.define([], function() {
return window.localStorage;
});
very good idea
Thanks. Checking now if this can be done with the oData model as well. Would be nice to easily store application master data and other non essential info
Hi,
Thanks for sharing this.
You can also use a SmartFilterBar to easily persist this kind of data in the backend: https://sapui5.netweaver.ondemand.com/#/entity/sap.ui.comp.smartfilterbar.SmartFilterBar and there's also a Personalization service for personalization data: https://sapui5.netweaver.ondemand.com/#/topic/1c60212834c049ed9f65d743dfeb3d9a
Cheers,
Pierre
Hi Pierre,
Thanks for the alternatives. The backend storage is nice on the smart filter bar With this model though, I don't need to change anything in my applications to store everything (but it only persist in browser).
This is very cool. Just to flag to those who might not think about it, that local storage is not secure, so don't store anything that that a hacker with access to your machine shouldn't be able to see. (To be fair if a hacker has access to your machine, you're screwed anyway ?)
Nice one Jorg
Thanks Chris! I’ve been using it for over a year, I can’t believe I haven’t told anyone about it before
As you mentioned: nifty and easy for those who use JSON-Model
Thanks
Nice work Jorg Thuijls and thanks for sharing. I use JSON models a lot and I have also used local storage to store a few filters and the like. In my experience, small steps like this can make a big difference to UX.
As Chris Paine points out we probably should emphasise to people that they shouldn't ever put anything sensitive in there, as it isn't meant to be secure.
Aside from the point Chris made about people having access to the machine (or using a shared machine) any data in local storage would be vulnerable to a cross-site scripting (XSS) attack. It seems pretty unlikely for a UI5 app but it's becomes more likely as we add outside code like external libraries, Google Analytics etc. Also, there is a move to download the UI5 libraries from a Content Delivery Network (CDN).
I would probably name the JSON model something like "public" so it's clear sensitive data shouldn't go in there.
Very nice! And simple too, thanks! I will take that!
I would even support a pull to the openui5 project on this. Indeed there is a lot of situations where this can improve UX.
Felipe