Autosaving with Vuex

isaacdlyman

Isaac Lyman

Posted on February 27, 2018

Autosaving with Vuex

Some background

Back in the early 2000s, if you'd been working on a Microsoft Word document and shut down your computer without hitting Ctrl+S, you were in a bad spot. All your work was gone and there was no way to get it back. In future versions of Word they introduced an autorecovery tool, which if you were lucky would offer to get most of it back for you. That was nice, but the real game changer was Google Docs, with its always-vigilant Saved indicator, noticing whenever you changed the document and saving it to the cloud automatically every couple of seconds.

Autosave is table stakes now. If your app allows a user to enter content, they no longer look for an explicit "Save" button. They expect it to be uploaded to the cloud almost as fast as they can type it. Luckily, in modern webapps it's not too hard to implement it.

Today I'll show you how to write an autosaver using Vue.js, Vuex, and a Vuex plugin. The nice thing about this method is that it doesn't require your app to explicitly send API requests every time there's a change; instead, the Vuex plugin observes the state of the app and responds when it needs to, decoupling the inner workings of your app from its communications with the API. I've implemented this in my hobby project, Edward, and it runs like a dream.

Ingredients

Here are the tools we'll be using:

  • Vue.js is a simple and powerful framework for writing web components. It's similar in scope and paradigm to React, but is full of shortcuts that make it more fun to use.
  • Vuex is a state management tool for Vue. It's similar to Redux. It's an immutable state machine, which means it manages a giant object full of data your app needs, and every time the data changes it produces a whole new object.
  • Lodash is a JavaScript toolchain (much like Underscore or parts of jQuery) full of nice things. We only need one function from it today. And we could get by without it, if we wanted to.
  • A web browser.

The example repo is here. And guess what? There's no npm install needed, no build step (you can open index.html right in your browser), and the entire thing is under 100 lines of code (comments excluded). Plus, it's all in plain ES5! Please try to control your excitement.

How to do it

First, you'll need a basic index.html file. It will contain a div for Vue to attach to, <script> tags for the libraries we need, a <script> tag for our JavaScript file, and a <style> tag to make things look just a little bit nicer.

<body>
  <div id="app"></div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="https://unpkg.com/vuex@3.0.1/dist/vuex.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.5/lodash.min.js"></script>
<script src="./index.js"></script>
<style>
  textarea {
    height: 100px;
    width: 300px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

You can see we're pulling in Vue, Vuex, Lodash, and our own JavaScript file named index.js. And that's it for this file.

Create the index.js file. We'll start it out by bootstrapping our Vue app:

var app = new Vue({
  el: '#app',
  template: '<div></div>',
})
Enter fullscreen mode Exit fullscreen mode

You should keep this piece of code at the bottom of your index.js file, as it will refer to everything we build from here on out.

Vue will find the element that matches the el selector and take control of it. All it does at the moment is put another <div></div> inside of it. We'll have it do something more interesting in a moment.

Now let's create a component that lets the user enter text:

Vue.component('text-entry', {
  template: '<textarea v-model="content" @keyup="registerChange"></textarea>',
  data: function () {
    return {
      content: '' // This is the initial value of the textarea
    }
  },
  methods: {
    registerChange: function () {
      // We'll do something whenever the textarea changes
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

This will display a textarea and update the value of content whenever the user types in it. Let's add it to our app by updating the Vue constructor:

var app = new Vue({
  el: '#app',
  template: '<div> <text-entry></text-entry> </div>',
})
Enter fullscreen mode Exit fullscreen mode

Now we should see a textarea in our app. Next, we create a Vuex store:

var store = new Vuex.Store({
  state: {
    content: ''
  },
  mutations: {
    'UPDATE_CONTENT': function (state, newContent) {
      state.content = newContent
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

This is a pretty basic Vuex store. It has one piece of data and one mutation. When we commit the mutation, it will save whatever we pass in as the new value of content.

We need this store in three places:

  • Once in our app declaration so Vue knows to use it.
  • Once in our text-entry component's data declaration, so it will set the value of the textarea based on the Vuex state.
  • Once in our text-entry component's registerChange method, to commit a mutation every time the user changes the text in the textarea.

Once we do all of these, our index.js file should look like this:

var store = new Vuex.Store({
  state: {
    content: ''
  },
  mutations: {
    'UPDATE_CONTENT': function (state, newContent) {
      state.content = newContent
    }
  }
})

Vue.component('text-entry', {
  template: '<textarea v-model="content" @keyup="registerChange"></textarea>',
  data: function () {
    return {
      content: this.$store.state.content
    }
  },
  methods: {
    registerChange: function () {
      this.$store.commit('UPDATE_CONTENT', this.content)
    }
  }
})

var app = new Vue({
  el: '#app',
  template: '<div> <text-entry></text-entry> </div>',
  store: store
})
Enter fullscreen mode Exit fullscreen mode

To demonstrate our autosaving feature, we'll need a place to store data that will persist after a page refresh. I won't go to the trouble of creating a web server for this purpose. Let's use LocalStorage instead:

var storageKey = 'content'
var api = {
  load: function () {
    var json = window.localStorage.getItem(storageKey) || JSON.stringify('')
    return JSON.parse(json)
  },
  save: _.debounce(function (content, callback) {
    window.localStorage.setItem(storageKey, JSON.stringify(content))
    callback()
  }, 1000, { maxWait: 3000 })
}
Enter fullscreen mode Exit fullscreen mode

Our fake API has two methods, save and load. load attempts to get the app state out of LocalStorage, and if it isn't there, returns an empty string. save sets the value of our LocalStorage key, then invokes a callback. We're using Lodash's handy debounce method here to ensure that save is never called more than once per second. This is important because if we don't debounce the method, it will be called every time the user types a key. That isn't so bad for LocalStorage, but if you were doing XHR requests to an actual web server, a user doing 70 words per minute could be submitting several requests per second, which would slow things down for them and for you. I've also used the maxWait parameter, which ensures that if the user types continuously, the content is autosaved every three seconds.

Okay, now we can create a Vuex plugin to autosave the content of the textarea. A Vuex plugin is a function that accepts the Vuex store as an argument. It can then subscribe to the store in order to be notified of every state change.

var autosaverPlugin = function (store) {
  store.commit('UPDATE_CONTENT', api.load())

  store.subscribe(function (mutation, state) {
    if (mutation.type === 'UPDATE_CONTENT') {
      api.save(mutation.payload, function () {
        // This callback doesn't need to do anything yet
      })
      return
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

When the plugin is first loaded, we immediately load the application state from LocalStorage and commit it as a mutation. Then we subscribe to the Vuex store. Every time a mutation is committed, we'll be notified. We can check the mutation type to make sure it's a content update, then send the payload on to our fake API to save it in LocalStorage.

Now let's add this plugin to our Vuex declaration:

var store = new Vuex.Store({
  state: {
    content: ''
  },
  mutations: {
    'UPDATE_CONTENT': function (state, newContent) {
      state.content = newContent
    }
  },
  plugins: [autosaverPlugin]
})
Enter fullscreen mode Exit fullscreen mode

So far so good! If you type in the textarea, wait one second, and refresh the page, you'll see your changes persist. And your Vue component doesn't even have to worry about it; the Vuex plugin is doing all the heavy lifting.

One last touch

This is great, but we could use a way to indicate to the user that their work has been saved. This reassures the user and helps you to see that the app is working. Let's add some text that says either "Saving..." or "Saved".

First, let's add some state to the Vuex store:

var store = new Vuex.Store({
  state: {
    content: '',
    saveStatus: 'Saved'
  },
  mutations: {
    'SET_SAVE_STATUS': function (state, newSaveStatus) {
      state.saveStatus = newSaveStatus
    },
    'UPDATE_CONTENT': function (state, newContent) {
      state.content = newContent
    }
  },
  plugins: [autosaverPlugin]
})
Enter fullscreen mode Exit fullscreen mode

saveStatus will contain a string that indicates to the user whether their work has been saved. And SET_SAVE_STATUS will update it.

Now let's create a component that displays it:

Vue.component('saving-indicator', {
  template: '<div>{{ saveStatus }}</div>',
  computed: {
    saveStatus: function () {
      return this.$store.state.saveStatus
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

And let's display it above the textarea by modifying the app template:

var app = new Vue({
  el: '#app',
  template: '<div> <saving-indicator></saving-indicator> <text-entry></text-entry> </div>',
  store: store
})
Enter fullscreen mode Exit fullscreen mode

Now let's update our autosaver plugin to commit mutations to saveStatus:

var autosaverPlugin = function (store) {
  store.commit('UPDATE_CONTENT', api.load())

  store.subscribe(function (mutation, state) {
    if (mutation.type === 'UPDATE_CONTENT') {
      store.commit('SET_SAVE_STATUS', 'Saving...')
      api.save(mutation.payload, function () {
        store.commit('SET_SAVE_STATUS', 'Saved')
      })
      return
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

And, at long last, our index.js file looks like the one in the example repo. Take a look here: https://github.com/isaaclyman/vuex-autosaver/blob/master/index.js

Try it out! When you type in the textarea, the message says "Saving..." and once you finish, it says "Saved". Just like in Google Docs. This is some superstar web development right here.

Questions? Corrections? Leave me a comment.

Homework

Here are some things you could add to the project to help you get your feet wet:

  • The saving indicator could say "Error" if an error is thrown by the api.save method.
  • The fake API could use a timeout in order to simulate a slow XHR request.
  • The fake API could also return a Promise instead of accepting a callback.
  • There could be two textareas, and each of them could be autosaved with as little code duplication as possible. Hint: try autosaving an object instead of a string.
  • If api.save doesn't receive a callback, it currently throws an error. It could guard against that situation.
  • Everything could be a whole lot more professional-looking. This is pretty much the Craigslist version of Google Docs.
💖 💪 🙅 🚩
isaacdlyman
Isaac Lyman

Posted on February 27, 2018

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Autosaving with Vuex
beginners Autosaving with Vuex

February 27, 2018