Persisting a vuex store to IndexedDB

Posted: 20 November, 2018 Category: code Tagged: vuexlocalForageindexeddboffline-firstPWApersistence

With a probability of 1, I did something wrong, and did not reap the benefits of vuex-persist, which you should take a gander at, instead of doing what I do below. It will prolly save you time.

I got stuck with vuex-persist because of another plugin, localForage. (It turns out my invocations of localforage were not working... and so as far as I could tell, vuex-persist wasn't working, and I prematurely blamed the latter module).

I found some other invocation pattern in which localForage's setDriver() was called right after instantiation. THAT seemed to work. Do I know why? Do I care why? No and no. Not today. Not everything needs to be a learning moment. I have stuff to build.

Luckily, I came across this drop-dead GORGEOUS page. A page which led to a series of blindingly-bright insights. Heck, were I on Medium, I'd give it all. my. claps. (note to self: I gotta emoji-fy this blog). Having discovered the entire foundational premises of how persistence at the vuex coalface ought to work, I calmly npm uninstall'd vuex-persist and rolled my own thing. A not-elegant, not-super-well-thought-out thing, mind, but a working thing.

I'm lying if that was my only "A-ha" moment. Somewhere in my half-blind meanderings into PWA/offline-first/persistence -space, the Service Worker meme had become so entrenched that all subsequent npm package installs became tainted with an erroneous thought: *"this requires service workers in order to work"... and it simply was not true. I don't know WHERE I got this idea that *A implied B. Sometimes the brain fabricates things.

And so what I have ended up doing, in order to cache data in-browser using indexeddb, is more or less the following. In the examples, all the files related to persistence are in the same folder as the code for my vuex store, for simplicity. You can put your things wherever you want, obv.

  1. do: npm install --save localforage

  2. create localForageService.js contents along the lines of:

    import localforage from 'localforage';
    
    export const localForageService = localforage.createInstance({
      name: "mydatabase",
      version : 1.0,
      storeName : 'mystorageobj'
    });
    
    // THIS style of invocation is what worked for me... not the variant on the gh readme. see https://codepen.io/thgreasi/pen/ojYKeE?editors=1111
    localForageService.setDriver([
      localforage.INDEXEDDB,
      localforage.WEBSQL,
      localforage.LOCALSTORAGE
    ])
    .then(() => {
      // use this to test your db connection - delete later
      localForageService.setItem('testkey', 'testvalue', function() {
        console.log('Of the driver options given, localforage is using:' + localForageService.driver());
      });
    })
    .catch(error => {
      // welp. you can't have nice things.
    });
    
    export default {
      localForageService
    }
  3. Then you go read the super helpful article I told you about and then come back (or hell, don't... you can learn everything you need there)... and then create something like stateMapper.js which needs to basically follow this template:

    import { localForageService } from './localForageService';
    
    export const setPersistedState = (state) => {
      const persistedState = mapToPersistedState(state);
    };
    
    export const getPersistedState = () => {
      // u decide how to fetch from cache
    }
    
    export const deletePersistedState = () => {
      // u decide how to delete cache
    }
    
    export const mapToPersistedState = (state) => {
      // whatever your business logic is.
    };

    Tip: Apparently it's not a great idea to store your entire state as a giant json. I wanted to see what it would be like to step away from that paradigm, so I actually break down one of the arrays in my state and store each item as a separate object, using its id as the storage key. Example:

      const promises = [];
      return new Promise((resolve, reject) => {
        persistedState.arrayOfThings.forEach(thing => {
          promises.push(localForageService.setItem(thing.id, thing));
        });
    
        Promise.all(promises)
        .then(result => {
          resolve(result);
        })
        .catch(error => {
          reject(error);
        })
      });

    (Actually, doing the above caused something ELSE to blow up in my face. More on this in a bit).

  4. Now define a plugin for vuex that will respond to mutations of your store, and hand over as needed to your mappings from vuex to persisted state, ultimately updating indexeddb. You could call it e.g. persistencePlugin.js:

    // import whatever u need from your 'statemapper'... this is just an example.
    import { setPersistedState } from './statemapper'; 
    
    //decide which mutations you want to listen in on, for persisting app data
    const mutationsOfInterest = [
      'doThis',
      'doThat',
      'someOtherThing'
    ];
    
    const ofInterest = (mutation) => {
      return mutationsOfInterest.includes(mutation);
    };
    
    export const persistencePlugin = (store) => {
      store.subscribe((mutation, state) => {
        if (ofInterest(mutation.type)) {
          // handover to relevant get/set mappings. straightfwd example would be:
          setPersistedState(state).catch(error => {
            // wow why so unlucky... handle error here
          }); 
        }
      });
    };
  5. Next, plug-in your plugin! (in store.js or wherever your vuex store is defined):

    import Vue from 'vue';
    import Vuex from 'vuex';
    import { persistencePlugin } from './persistencePlugin';
    Vue.use(Vuex);
    
    export const store = new Vuex.Store({
      state: {
      },
      mutations: {
      },
      getters: {
      },
      plugins: [persistencePlugin] // <== PLUG IT IN HERE!!
    });
    
    export default {
      store
    }

    At this point, if you poke your app in a browser and try to save something (for example), you should see your indexeddb store get populated. Here's my initial screenshot of data created during some 'add' mutations: data created on 'add' mutations

    Tweak your code as needed to test updates and deletes are working the way you want too.

  6. The last piece of the puzzle now is restoring the persisted data back into the vuex store on startup. This is where I personally ran into problems, because I was decomposing an array into individual items (see the "tip" in step 3). So now I needed to recompose the items back into an array, and basically had to fetch all the items. Well guess what: under the covers, the localForage plugin did not support such a batch get. I'd have to recursively call "getItem()". WHAT??!! Nah...

    And so a slight detour... After a bit of reading through the issues backlog for localForage, I was led to the localForage-getitems plugin, which basically extends the localForage object prototype to add a 'getItems()' method which behaves as you'd expect. So I:

    • did npm i localForage-getitems to install the module
    • went back to my version of localForageService.js and imported the new module:
    import localforage from 'localforage';
    import localforageGetItems from 'localforage-getitems'; // added line.

    That's it. Merely importing it executes the prototype extension needed. Here's how I used it in my equivalent of statemapper.js:

    export const getPersistedState = (fetchKey) => {
      if(fetchKey) {
        return localForageService.getItem(fetchKey);
      } else {
        // get everything!!
        return localForageService.getItems().then(resultObj => {
          return Promise.resolve(Object.values(resultObj));
        });
      }
    }

    And... we're back from the detour.

  7. Now when the app loads, you want to populate it with the data that's been saved to indexeddb! First, I made sure the store had an initialize method which would allow the app to be refreshed from saved data. It's basically a mutation, so add it to the mutations object in your store.js:

    mutations: {
      initialize(state, persistedState) {
        // use the fetched, persisted state.
        // In my case, I was reconstructing an array, so I wrote a handler (overwriteStore) to deal with the data rather simplistically
        overwriteStore(state, { arrayOfThings: persistedState }); // but do your own thing here.
        Vue.set(state, 'initialized', true);
      },
      ...
      ...
    }
  8. Next, I just modified my main App.vue file and stuck the logic in the mounted stage of the app lifecycle:

      mounted() {
        // rem:
        // add an 'initialized' flag to your vuex store state and the necessary business logic
        // import whatever method fetches all the data you need at startup.
        // e.g. using "getPersistedState" from statemapper file per earlier examples
        if (!this.$store.initialized) {
          getPersistedState()
          .then(persistedState => {
            this.$store.commit('initialize', persistedState);
          })
          .catch(error => {
            // tsk tsk... handle this error too
          });
        }
      }

    ... and do you know what? IT WORKS!!! whoooo!!! Killing the app and reopening in a new tab reloads previous data! \o/


So that's it. Hope some of the concepts translate.

You may be dismayed (don't be!) to know that:

  • I actually might rip all this out and go with something like levelDB in the future. I'm still glad I went this path first: I know a tiny bit more about what's going on under the covers.
  • There's still the other half of the persistence/offline-first architecture that I haven't even attempted yet: syncing with the cloud.