Persisting Redux State to Local Storage

I have this widget I call Draft. It’s the one I discussed in States Within States (parts 1 and 2). It is a simple plain text editor that allows you to commit your changes at any stage as you work and then browse back to look at the commit history. The source code is at https://github.com/xerocross/xero.draft. I think of it as a way to draft an email if you are obsessive about getting the tone exactly right.

Since this widget is still a work in progress, I’m going to create a snapshot of the code as it exists today, right now, as follows. While on my master branch, I run git checkout -b 4-29-2019. This duplicates master into a new branch called 4-29-2019. This new branch is now pushed to GitHub and it will not be altered, and now I checkout master again.

As discussed in “States Within States”, one of my goals is to make the widget automatically persist its state to local storage as the user works. This required some thoughtful consideration about exactly what the state of the widget is. Ultimately, we had to completely change the schema of the state object and change the way our “commits” are stored—from a linked list of objects to an array. The reason for all those changes was that to store the state you first have to have an explicit representation of the state. Previously, the app’s state only existed implicitly as a collection of objects in memory tied together by references.

Now that we have a clear and explicit representation of the app’s state, it should be a simple matter to persist state to local storage. The only challenge, if any, is how to project the state object into a string form and then, upon reloading the browser window, how to translate that back into a valid state object.

I plan to roll my own functions for that, but they will mainly just be loose wrappers around JavaScript’s JSON.stringify and JSON.parse.

Let’s create a new branch from master called add-local-storage. The functions for translating state back and forth with a string representation—I’m going to call that bottle and unBottle and put them in a bottle.js file.

I have an object I created called Commit. Right now a commit is just an immutable wrapper object around a string, but I decided to make it an object in case for some reason in the future I want to add more data (like date/time for example). A custom object like this may not behave the way we want if we just try to run JSON.stringify on it directly. However, we can override a method toJSON on the object. Then, whenever we execute JSON.stringify on one of these objects, the toJSON method will be called to supply the correct string representation.

Writing a toJSON method for a Commit object is actually all we need to do so that now JSON.stringify(stateObject) produces a meaningful representation of state. So our bottle function will just return exactly that. However, it’s nice to wrap JSON.stringify in our own function in case for some reason in the future we need to do more processing than just that. We have one place where we can put that.

Unbottling will require explicitly parsing the JSON form of each Commit object. The representation will just be a string. The JSON.parse method would have no way of knowing that such a string is supposed to be parsed into a Commit object.

We will start with JSON parsing: let parsedObject = JSON.parse(stateString), but right now parsedObject.commits is an array of strings. So let’s pass it through a loop that properly parses each of those strings into a Commit object. Also, any time you use JSON.parse, it should be wrapped in a try block because any kind of formatting error will throw a syntax error. This should work.

export function unBottle(stateString) {
    try {
        let parsedObject = JSON.parse(stateString);
        let commitArray = [];
        for (let i = 0; i < parsedObject.commits.length; i++) {
            commitArray.push(new Commit(parsedObject.commits[i]))
        }
        parsedObject.commits = commitArray;
        return parsedObject;
    }
    catch (e) {
        return null;
    }
}

Now that we have a bottle function, it is a trivial matter to persist state to local storage every time it is changed. This may work OK for us, but bear in mind that we are talking about stringifying the entire object and storing that string—at every keystroke. I think even for a tiny widget like this we should make things a little more efficient than that. For a project with a large state, we would almost certainly want to use a more sophisticated technique for persisting to storage. That said: let’s just make something that works and then see if we need to make it more efficient.

Where should we insert the code that actually bottles the state and persists it to storage? Here’s one option: somewhere, perhaps in a top-level component, we can add the following code.

store.subscribe(() => {
    let state = this.store.getState();
    localStorage.setItem("state", bottle(state));
}

This certainly works. On my own computer, I don’t notice any amount of lagging as I type new text even though it is bottling and persisting the state for each keystroke. Again: far from ideal, but quick and dirty and so far it works. Let’s figure out how to unbottle and then we’ll get back to the fact that I hate having references to a global written into components like this.

States come from the reducer function. So: do I check localStorage inside the reducer function to get the initial state? That’s seems gross. Instead of having references to these globals spread all about, let’s put all of this kind of logic inside our bottle.js file. We will add functions getStateFromStorage and persistStateToStorage. Now we will only expose those two functions, and all the logic for how they work is contained in this single file. Inside our reducer function, we can include some logic like this.

if (typeof state === "undefined") {
    let stateFromStorage = getStateFromStorage();
    if (stateFromStorage !== null) {
        return Object.freeze(clone(stateFromStorage))
    }
    return Object.freeze(clone(initState));
}

With these changes in place, refreshing the page no longer erases one’s data. It is always automatically saved. We do of course have a “clear all” button if the user wants to delete all data. Now is a good time to run all the tests and make sure I haven’t broken anything. Everything appears to be in order, so I commit my changes.

I still don’t like the idea of performing this potentially lengthy operation at every keystroke. These local storage updates gets increasingly lengthy as the size of the text increases and as the number of commits increases. Even so, I don’t think the use cases of a widget like this call for a lot of hand-tooling for efficiency. Just a little. Just something a little better than rewriting the entire object string at every keystroke.

My first thought is to debounce keystroke updates. We can use lodash.debounce for that. We can install that dependency without installing the entire lodash library. My second thought is to debounce our calls to persistStateToStorage. That makes a lot more sense. So we add something like this.

this.updateStorage = debounce((state) => {
    console.log("updating storage");
    persistStateToStorage(state);
}, 200);

And now inside store.subscribe we don’t call persistStateToStorage directly. We call updateStorage. That works nicely. If you stop typing for 200 milliseconds, then your changes persist to storage.

It looks like we accomplished our goal. The data is persistent, and all my tests are passing. I have not written any new tests to specifically test this local storage behavior though. Let’s make a note of that, and maybe I’ll talk about writing those tests another time. For now, I’m going to commit and then merge these changes into master. Done. Now I merge master into prod and deploy to Heroku. Cool. It works there too, as expected: https://xero-draft.herokuapp.com/

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s