Effective Application State Management - Lesson 3

Reading Time: 6 minutes

Make your state immutable

Yes! I highly recommend to make the application state immutable. Most developers I know totally underestimate or even don't consider the risk of mutable resources that are shared - and application state is shared.

Let's assume we have an application with a list of items, that is shared among several components. One component serves as an item editor, allowing to add new or edit an existing item. Another component is a search filter allowing to alter the list of visible items, and one more component visualizes the list. It is a perfect scenario to keep the item list as part of the application state in a globally accessible state manager.

Item List Components

 

The application state may look like this

const state = {
    "items": [
    {
        "text": "Item 1"
    },
    {
        "text": "Item 2"
    },
    {
        "text": "Item 3"
    }, 
    /* and so on */
  ],
    "query": ""
}

Remember that our state is managed in a global manner, e.g. a global Model aka Store or even a Flux/Redux Store. So, we can access our state using something like store.getState(). Now, we want to add a new item to our list and in a first try we would implement in the following way:

class ItemList extends Component {

    constructor() {
        this.state.items = Store.getState().items // !!! Copy by reference
    }

    // assume this method is called when our store was updated
    onStoreUpdate() {
      this.setState({items: Store.getState().items }) // !!! Copy by reference
    }

    addItem(item) {        
      let items = this.state.items /// !!! references the application state
      items.push(item) // !!! Application State is already altered here!
      Store.update({items: items}) // and only now the changes are propagated globally     
    }

    render() {
      return ( 
        <ItemEditor onAdd = {this.addItem} />
        /*...*/
      )
    }
}

This is just a React-like pseudo-code. Additionally, the careful reader might also ask why items are global, because the way it is shown here, there's no need to keep 'items' as application state (I'll discuss this in another lesson). I'm aware of this. It's merely for demonstration purposes

Copy-by-ref is not free of side-effects

This code works, but has a problem: items are copied by reference only. When pushing the item to the supposed local state, the store's item list - the global application state - is modified also, as the local state is only a reference to the store's array. This leads to weird side-effects as other components are not aware of this change (they aren't sync'ed with the application state). It may even break the application, if for example the component transforms the item objects because of some internal implementation details.

There're two approaches to deal with this situation: We clone the item list, and/or we make it immutable

Approach 1: Clone State

The simplest way is to clone the state (or parts of it). When opting for clone, we should do this deeply using for example JSON.parse(JSON.stringify(o)), or the far more robust, but slower lodash.cloneDeep.

    addItem(item) {        
      let items = _.cloneDeep(this.state.items) 
      items.push(item) // now we work on a true copy, and the store is not mutated
      Store.update({items: items})
    }

Now, any modification on the local items doesn't affect the global state anymore. Obviously, this comes with some costs. Depending on the complexity of your item list objects, cloning can demand a lot of computational resources and can severely impact your applications performance. Furthermore, you must ensure that everybody who works on your code follows this convention, which in practice is not as easy as it seems.

You may think of return always a cloned state on Store.getState(), but this function is supposed to be called very often, so the performance impact is even larger. And if you create a clone on each Store.update() you're in the same situation as before, because from the perspective of receiving components, it makes no difference at all - the state is still mutable.

Approach 2: Immutable State

With immutability we can achieve consistently shared data without being concerned about accidental mutation. This is extremely useful, when one or more teams work on a larger code base as undesired side-effects due to an unsynchronized application state are not possible. When used effectively, immutable data structures can boost performance: Immutable data structure can be copied by reference, which is a really fast operation and doesn't occupy nearly any additional memory space. Furthermore, you can compare the immutable objects by its references only, i.e. you don't need compare complex objects deeply.

Assuming our Store keeps an immutable state, then our ItemList can be coded more or less like this:

class ItemList extends Component {

    constructor() {
        this.state.items = Store.getImmutableState().get('items') // Copy by reference for immutable data is ok!
    }

    // assume this method is called when our store was updated
    onStoreUpdate() {
      this.setState({items: Store.getImmutableState().get('items')})
    }

    // optimize our rendering process
    shouldComponentUpdate(nextProps, nextState){
       if(this.state.items === nextState.items) // comparing by reference
          return false

       return true
    }

    addItem(item) {        
      // you cannot simply add an item to the list, you need to use the API
      let copiedItems = Immutable(this.state.items).asMutable()
      copiedItem.push(item)
      Store.update({items: copiedItems}) // assumes that update() makes the store somehow immutable
    }

    render() {
      return ( 
        <ItemEditor onAdd = {this.addItem} />
        /*...*/
      )
    }
}

As we can see in this adapted example, immutable data can be used easily and securely for getting a reference to the state. In performance critical sections the rendering process can be significantly boosted, as simple reference comparison is sufficient to detect whether the state has changed (this is because, changing immutable data usually results in new objects).

How to apply immutability?

Well, Javascript doesn't support true immutability natively. You can use Object.freeze() to make objects immutable, but to do this for deeply nested objects it turns out to be tedious, especially if you want to change frozen objects. There's no way around to create a (deep) copy, reassign and refreeze again. Nevertheless, in many cases it is sufficient.

You also may use special libraries like ImmutableJS, seamless-immutable, or crio. These libraries offer extensive APIs (as depicted in the latter example) for objects and most common data types in Javascript, and facilitate most common operations. Worth a note, that ImmutableJS is the most sophisticated solution using advanced data sharing techniques to efficiently provide immutability, but doesn't interoperate seamlessly with Javascript (it uses wrappers) and weights impressive 51 kiB.

This lesson was quite extensive, but Immutability is very important for effective application state management. Keep in mind, that all lessons in this series are somehow intertwined and complementary; for example using immutability on deeply nested data structures is unwieldy, and you should keep your states as flat as possible. I'll talk about this soon.

Facebooktwittergoogle_plusredditpinterestlinkedin

Leave a Reply

Your email address will not be published. Required fields are marked *