Step 6: Adding LocalStorage to our Vue.js Application
This is part 6 of an 18 part series titled Vue.js Application Tutorial - Creating a Simple Budgeting App with Vue. It is highly recommended that you read the series in order, as each section builds on the last.
- Step 0: Intro
- Step 1: Planning Your Application
- Step 2: Data Architecture
- Step 3: Setup & Project Structure
- Step 4: Create & View Accounts
- Step 5: Edit & Delete Accounts
- Step 6: Adding LocalStorage to our Vue.js Application
- Step 7: Interlude & Refactor
- Step 8: Budgeting
- Step 9: Racing Through Budgets
- Step 10: Styling & Navigation
- Step 11: Finishing Budgets with Vue.js Dynamic Components
- Step 12: Planning for Transactions
- Step 13 - All Aboard the Transaction Train
- Step 14 - User Testing
The next entry is expected to be published on 03 August 2017. Sign up using the form at the bottom of this post to receive updates when posts are published.
I don't know about you, but I'm tired of retyping account names every time I load or change Budgeterbium. Let's fix that and add some permanency!
We don't want to use a server, so we are relying on storing data in the user's browser. Sure, it has some limitations, but it's easy and somewhat secure. The state of local storage is... iffy? Confusing? In a state of constant flux? So we're going to mostly ignore it and use a simple JavaScript plugin to interface with it. localForage provides a simple and unified API to IndexedDB, WebSQL, and localStorage.
This is all it takes to store data with localForage and retrieve it.
localforage.setItem('key', 'francis scott?').then((value) => {
console.log('woot! we saved ' + value);
}).catch((err) => {
console.log('he\'s dead, jim!');
});
// next time we load the page we can do this
localforage.getItem('key').then((value) => {
console.log('oh say can you see, ' + value);
}).catch((err) => {
console.log('the rockets red glare has blinded me');
});
That is just about all it takes.
Before we store any data we can configure our database. localForage lets us set its name and its size, as well as choosing between the 3 storage driver options. IndexedDB is the default, and that is what we want, so we don't need to do anything there.
We installed localForage back in step 3, so all we need to do is load it when our application loads so that we can configure the database.
// src/main.js
import Vue from 'vue';
import localforage from 'localforage';
import 'bulma/css/bulma.css';
import { App } from './app';
import router from './router';
import store from './store';
localforage.config({
name: 'budgeterbium'
});
...
Just like that we're ready to store some data! As usual, we want to think it through before we jump in and write any code. The first thing that comes to mind is that we want to ensure data consistency. The best way to do this is to have only one place that ever writes or recalls data. We already have our vuex store in place. That seems perfect.
Remember when we talked about the reason we use actions instead of calling mutators directly? The biggest reason is that actions can be asynchronous while mutators are not. IndexedDB operates asynchronously - so we shouldn't put any database related code in our mutators. Mutators should only ever be concerned with storing or retrieving information from the vuex store. The act of storing and retieving information from a database lies outside of that boundary, so we use actions to call mutations and perform any other tasks related to them.
Each IndexedDB database is composed of key-value tables. Instead of using separate tables for each object type - account, budget, and transaction - we are going to prepend the key with an identifier. We are going to store each account in our database as a record, or a key-value pair. We could store the entire accounts
object as a single record, but that bypasses some of the benefits and features of IndexedDB. Plus, storing each item as its own record allows us to save individual items without committing the entire list. So an account with the ID of 2df9c687
will be saved with the key ACCOUNT-2df9c687
.
When a user adds, updates, or deletes an account, we want to save it to to the database. Let's see what that code looks like right now.
// /src/app/accounts/vuex/actions.js
export const addAccount = ({ commit }, data) => {
commit('ADD_ACCOUNT', { account: data });
};
export const updateAccount = ({ commit }, data) => {
commit('UPDATE_ACCOUNT', { account: data });
};
export const deleteAccount = ({ commit }, data) => {
commit('DELETE_ACCOUNT', { account: data });
};
Simple enough - each action commits to the store using the corresponding mutator. Some questions have come to mind while looking at this code.
- Saving to local storage is an asynchronous operation - do I commit to the store before or after saving?
- If I commit after saving, how do I tell the user that their new account will show up after we save?
- If I commit before saving, how do I handle any errors that occurred while saving?
- How can I save a new account before committing when I don't yet have the generated ID?
There is no right or wrong answer to whether you should save before or after - it depends on the needs and design of your application. For Budgeterbium, we want things to happen instantaneously whenever we can. No waiting on servers to respond or on storage to save. That means we will commit to the store first, then save to storage while the user continues to use the application.
That still leaves us a problem with the ID - we had previously chosen to generate the ID in the mutator. We never call the mutator directly, always proxying through commit
. Unfortunately commit
doesn't return anything, so there is no way to directly retrieve the account we just created. This means we have to generate the ID in our action. Anyway, generating the ID is probably outside the scope of a mutator's purpose, woops.
import Vue from 'vue';
export default {
ADD_ACCOUNT (state, payload) {
state.accounts[payload.account.id] = payload.account;
},
UPDATE_ACCOUNT (state, payload) {
state.accounts[payload.account.id] = payload.account;
},
DELETE_ACCOUNT (state, payload) {
Vue.delete(state.accounts, payload.account.id);
}
};
import localforage from 'localforage';
import { guid } from '../../../utils';
const ACCOUNT_NAMESPACE = 'ACCOUNT-';
const saveAccount = (account) => {
return localforage.setItem(
ACCOUNT_NAMESPACE + account.id,
account
).then((value) => {
return value;
}).catch((err) => {
console.log('oops! the account was too far gone, there was nothing we could do to save him ', err);
});
};
export const addAccount = ({ commit }, data) => {
let id = guid();
let account = Object.assign({ id: id }, data);
commit('ADD_ACCOUNT', {account: account});
saveAccount(account).then((value) => {
// we've saved the account, what now
});
};
...
Great. We're generating the new account ID in our action function, we're committing to the Vuex store, then we're saving the created account through localForage. We're not doing any sort of real-world error handling or data verification - two things that are very important in production code! Try adding a new account right now, but without filling in any of the fields. Jot these down in your programmer's notebook as items to still be handled.
It's worth noting that ADD_ACCOUNT
and UPDATE_ACCOUNT
are now identical. You are probably itching to make your code DRY. I kept them separate for semantic purposes, and we might later need to perform different functions with each different mutation. It's easier to separate them now than to refactor later and try to figure out where you should update and where you should add.
We can now create accounts and see them in our database.
Of course, we still don't see the accounts we loaded when we refresh the page because we never load them. Before we get to that, let's write the handler for updating and deleting objects in our database. I also don't like seeing the the localstorage code in our actions.js
file - it doesn't belong there. We're going to create an api.js file and move those functions into it.
import { guid } from '../../../utils';
import { removeAccount, saveAccount } from '../api';
export const addAccount = ({ commit }, data) => {
let id = guid();
let account = Object.assign({ id: id }, data);
commit('ADD_ACCOUNT', {account: account});
saveAccount(account).then((value) => {
// we've saved the account, what now
});
};
export const updateAccount = ({ commit }, data) => {
commit('UPDATE_ACCOUNT', {account: data});
saveAccount(data);
};
export const deleteAccount = ({ commit }, data) => {
commit('DELETE_ACCOUNT', { account: data });
removeAccount(data);
};
import localforage from 'localforage';
const ACCOUNT_NAMESPACE = 'ACCOUNT-';
export const saveAccount = (account) => {
return localforage.setItem(
ACCOUNT_NAMESPACE + account.id,
account
).then((value) => {
return value;
}).catch((err) => {
console.log('oops! the account was too far gone, there was nothing we could do to save him ', err);
});
};
export const removeAccount = (account) => {
return localforage.removeItem(
ACCOUNT_NAMESPACE + account.id
).then(() => {
return true;
}).catch((err) => {
console.log(err);
return false;
});
};
The great thing about this setup is that we could decide to change our API later - maybe we want to move to a server backend instead of IndexedDB - and the only code we have to change is in api.js
. As long as we keep the removeAccount
and saveAccount
functions operating the same way, the rest of the application doesn't care how our data is persisted.
Our final step is to load the stored accounts when our application loads. Or more precisely - we load them when we need them. Right now we need to access data in two different scenarios - we need the entire list of accounts when the user views the accounts page; and we need a single account when editing an object.
It would be great if we could hook into the Vuex getters to load the data on demand. Unfortunately, the getters don't have access to the entire store. They receive the state, but they can't dispatch actions.
This leads us to hook into the Vue.js component lifecycle to load the data if it isn't already loaded. We'll add the mutator and action for loading the accounts, and the API function to recall the data from our database.
Before writing all of this code, we're going to add one more small tool to help us load objects from IndexedDB. localForage-startsWith
lets us easily load all keys that begin with a string we give it. In our case that is ACCOUNT_NAMESPACE
.
npm install --save localforage-startswith
// /src/main.js
import Vue from 'vue';
import localforage from 'localforage';
require('localforage-startswith');
...
Now we can call localforage.startsWith(ACCOUNT_NAMESPACE)
to grab all of the records that start with our identifier. There is a lot happening in the next step, follow closely!
- src/app/accounts/vuex/actions.js
- src/app/accounts/api.js
- src/app/accounts/vuex/mutations.js
- src/app/accounts/components/CreateEditAccount.vue
import { guid } from '../../../utils';
import { removeAccount, saveAccount, fetchAccounts } from '../api';
export const addAccount = ({ commit }, data) => {
let id = guid();
let account = Object.assign({ id: id }, data); // copy the data into a new object with the generated ID
commit('ADD_ACCOUNT', {account: account});
saveAccount(account).then((value) => {
// we've saved the account, what now?
});
};
export const updateAccount = ({ commit }, data) => {
commit('UPDATE_ACCOUNT', {account: data});
saveAccount(data);
};
export const deleteAccount = ({ commit }, data) => {
commit('DELETE_ACCOUNT', { account: data });
removeAccount(data);
};
export const loadAccounts = (state) => {
// loads accounts only if they are not already loaded
// later we might want to be able to force reload them
if (!state.accounts || Object.keys(state.accounts).length === 0) {
return fetchAccounts().then((res) => {
let accounts = {};
Object.keys(res).forEach((key) => { accounts[res[key].id] = res[key]; });
state.commit('LOAD_ACCOUNTS', accounts);
});
}
};
import localforage from 'localforage';
const ACCOUNT_NAMESPACE = 'ACCOUNT-';
export const fetchAccounts = () => {
return localforage.startsWith(ACCOUNT_NAMESPACE).then((res) => {
return res;
});
};
export const saveAccount = (account) => {
return localforage.setItem(
ACCOUNT_NAMESPACE + account.id,
account
).then((value) => {
return value;
}).catch((err) => {
console.log('oops! the account was too far gone, there was nothing we could do to save him ', err);
});
};
export const removeAccount = (account) => {
return localforage.removeItem(
ACCOUNT_NAMESPACE + account.id
).then(() => {
return true;
}).catch((err) => {
console.log(err);
return false;
});
};
import Vue from 'vue';
export default {
ADD_ACCOUNT (state, payload) {
state.accounts[payload.account.id] = payload.account;
},
UPDATE_ACCOUNT (state, payload) {
state.accounts[payload.account.id] = payload.account;
},
DELETE_ACCOUNT (state, payload) {
Vue.delete(state.accounts, payload.account.id);
},
LOAD_ACCOUNTS (state, payload) {
state.accounts = payload;
}
};
...
mounted () {
if ('accountId' in this.$route.params) {
this.loadAccounts().then(() => {
let selectedAccount = this.getAccountById(this.$route.params.accountId);
if (selectedAccount) {
this.editing = true;
this.selectedAccount = {
name: selectedAccount.name,
category: selectedAccount.category,
id: selectedAccount.id
};
}
// TODO: the object does not exist, how do we handle this scenario?
});
}
},
methods: {
...mapActions([
'addAccount',
'updateAccount',
'loadAccounts'
]),
...
If the user is trying to edit an account, we call loadAccounts
in our mounted
hook. This checks if accounts are loaded, and if not, loads them from our API. If this was a more complicated application we might have to deal with more scenarios here - what if someone else changed the data on the API end and we need to reload it? - but for our simple purposes, we assume that the copy of the data the user is working with in the Vuex store is the most up to date data, so we never load data from the database if it has already been loaded. (Even with our app this could cause issues if you are using it in multiple tabs, so use caution!) It's important that we let our Vuex store decide this and not our API, which should only ever be responsible for querying data.
Once the data is loaded, the component gets the account the user is attempting to edit. If you are editing an existing account and refresh the page you should see the data still there.
Finally, we need to load the accounts on the list page if needed.
// /src/app/accounts/components/AccountsListView.vue
...
data () {
return {
categories: CATEGORIES
};
},
mounted () {
this.loadAccounts();
},
methods: {
...mapActions([
'deleteAccount',
'loadAccounts'
]),
...
Now we're in business! We can reload the application all day long and still see our accounts. Once again, the components never have to concern themselves with where data comes from. They request it, and either it exists or it doesn't.
f144a6e
While we are not avoiding getting into the nitty gritty of IndexedDB, it's worth reading up on the big picture concepts involved.