Watch it on YouTube

Immutability means that variables cannot be modified once they have been set with data, which prevents unwanted changes from occurring.

Immutability and pure functions

The concept of immutability is strictly connected with pure functions. In pure functions:

  1. Return values are only determined by the input as these do not depend on any state or data other than the input itself.
  2. Their evaluation does not affect any other element that exists outside the functions themselves.

The major benefits of pure functions is that they are easy to test as their output is predictable which produces more robust code. As objects and arrays cannot be modified after they are created, any new change is stored into new copies of the same data structures as follows:

  1. The function receives the input.
  2. The function processes the data.
  3. If necessary, the result is stored into a copy of the data structure that contains the old information.

Important aspects of immutability

The following are four important aspects of immutability:

  1. Immutable data structures are thread safe as they should be written once and read many times.
  2. Immutability can generate overhead as multiple copies of data structures have to be created.
  3. Immutable data structures make code easier to debug and maintain.
  4. Immutable code might eventually be safer as it follows a predictable path.

The architecture of Redux

Redux is a JavaScript library designed to manage applications state and it can be useful to better understand the idea of immutability. In well written Redux applications, changes always occur following predictable patterns. Although all JavaScript arrays and objects can be modified at any time, Redux expects that all updates are done immutably. Moreover, each Redux application has a single store that contains all application data (state). A basic Redux application will contain:

  1. Actions, the only functions that are allowed to change the store state by sending information to it. This is done by using the ‘dispatch()’ store method.
  2. Reducers, pure functions that define how the application state changes when an action is dispatched. More in particular, reducers take the application state and an action as input. Although Redux applications can define unlimited number of reducers while sharing the same store, they will not be able to access each other states and each other data.

How to build a Redux application

The following example is a Redux application designed to mimic a cluster management application with the following functionalities:

  1. Add and remove nodes to and from the cluster.
  2. Assign jobs to the nodes.
  3. Install new software onto the cluster with random failures.

First of all, the reader should make sure to include the Redux library as follows:

<HEAD>
   <SCRIPT TYPE="text/javascript" SRC="./redux.min.js"></SCRIPT>
</HEAD>

Next, a basic UI for the application is built as follows which should all go inside the <BODY> tag:

ADD NODES:
<BR>
<INPUT TYPE="text" PLACEHOLDER="Add new node" ID="nodename" />
<BUTTON ID="add">ADD</BUTTON>
<BR><BR>
REMOVE NODES:
<BR> 
<SELECT STYLE="width:180px" NAME="nodes" SIZE="1" ID="nodes">
</SELECT>
<BUTTON ID="remove">REMOVE</BUTTON>
<BR><BR>
<BUTTON ID="addJob">ASSIGN JOB</BUTTON>
<BUTTON ID="softwareInstall">INSTALL SOFTWARE</BUTTON>
<BR><BR>
<P ID='pJob'>ASSIGNED JOBS:</P>
<P ID='sUpdate'>UPDATE PROCESS</P>

From now on, all code presented in this tutorial will have to be enclosed into an Immediately Invoked Function Expression inside the HTML <SCRIPT> tag. Immediately Invoked Function Expression look like this:

(function () {
   //All Redux code goes here....
})();

Next, let’s define some constants to make the code more readable:

const CLUSTER = {
   ADD_TO_CLUSTER : 'ADD_TO_CLUSTER',
   REMOVE_FROM_CLUSTER : 'REMOVE_FROM_CLUSTER'
};
const STATUS = {
   ASSIGNED : 'ASSIGNED',
   UPDATED : 'UPDATED'
};

Let’s create the actions:

const newNode = function(text) { return { type : CLUSTER.ADD_TO_CLUSTER, text }; }
const deleteNode = function(text) { return { type : CLUSTER.REMOVE_FROM_CLUSTER, text }; }
const assignJob = function(id) { return { type : STATUS.ASSIGNED, id }; }
const softwareUpdate = function(success) { return { type : STATUS.UPDATED, success }; }

Next, let’s create two reducers:

const nodeManager = function(state = [], action) {                
   switch(action.type) {
      case 'ADD_TO_CLUSTER': {
         //Create a state with a new node
         return [ ...state, { text: action.text, jobs: { pid: [1], running: false } } ]
      }     
      case 'REMOVE_FROM_CLUSTER': {
         //Create a copy of the state without the node to be deleted                       
         return state.filter(({ text }) => text !== action.text);
      }
      //This will assign jobs to nodes
      case 'ASSIGNED': {
         return state.map((nodeManager) => {
            //This passes a new object, old data and new data
            return Object.assign({}, nodeManager, {
               //concat() does not modify the array, it makes a copy!
               jobs: { pid: nodeManager.jobs.pid.concat(action.id), running: true }
            })
         });
      }
      default: { return state }
   }
}
//This cannot see states created by nodeManager!!!
const clusterManager = function(state = [], action) {             
   switch(action.type) {
      case 'UPDATED': {
         return [ ...state, { success: action.success } ]
      } 
      default: { return state }
   }
}

The two reducers need to be combined and they will be sharing the same store:

const reducer = Redux.combineReducers({ nodeManager, clusterManager });
const store = Redux.createStore(reducer);

Let’s assign the actions to the elements of UI so that they can be dispatched:

//Add actions to buttons
document.getElementById('add').onclick = function(ev) {
   store.dispatch(newNode(document.getElementById('nodename').value));
   document.getElementById('nodename').value = null;
}
document.getElementById('remove').onclick = function(ev) {
   const sel = document.getElementById('nodes');
   store.dispatch(deleteNode(sel.options[sel.selectedIndex].value));
}
document.getElementById('addJob').onclick = function(ev) {
   const jobID = Math.floor((Math.random() * 1000) + 1);                
   store.dispatch(assignJob(jobID));
}
//Install software
document.getElementById('softwareInstall').onclick = function(ev) {
   const success = Math.floor((Math.random() * 2));                 
   store.dispatch(softwareUpdate(success));
}

The remaining code will manage the UI updates:

/*
   The state can only be changed by emitting an action. The state tree is never mutated directly
   Reducers take the current state tree and an action as arguments and returns the resulting state tree
*/
let prevState = store.getState();
const container = document.getElementById('nodes');
store.subscribe(() => {
   const state = store.getState();
   if(prevState === state) { return; }
   //Add a node to UI as the nodeManager store has been increasing
   if(prevState.nodeManager.length < state.nodeManager.length) {
      addToUI(state.nodeManager[state.nodeManager.length - 1].text);
   }
   //Delete a node from UI as the nodeManager store has been decreasing
   if(prevState.nodeManager.length > state.nodeManager.length) {
      document.getElementById('nodes').innerHTML = null; 
      state.nodeManager.forEach(element => addToUI(element.text));
   }
   //An update has just run as the clusterManager store has been increasing
   if(prevState.clusterManager.length < state.clusterManager.length) {
      runUpdate(state.clusterManager[state.clusterManager.length - 1].success);
   }
   //DEBUG START//
   console.log(state);
   const output = 'ASSIGNED JOBS:<BR>' + state.nodeManager[state.nodeManager.length - 1].jobs.pid.toString();
   document.getElementById('pJob').innerHTML = output;
   //DEBUG END//
   prevState = state;
});
const addToUI = function (computerName = null) {
   var sel = document.getElementById('nodes');
   var opt = document.createElement('option');
   opt.value = computerName;
   opt.innerHTML = '💻 ' + computerName;
   sel.appendChild(opt);
}
const runUpdate = function (success) {
   success = success === 0 ? 'OK' : 'FAIL';
   const output = 'UPDATE PROCESS:<BR>' + new Date().toLocaleString() + ' >> ' + success;
   document.getElementById('sUpdate').innerHTML = output;
}    

The reader should run the application while having the JavaScript debugging tools available as the application state will be available on the JavaScript console. In the console the reader should pay attention to the differences between the two reducers: ‘clusterManager’ and ‘nodeManager’. The reader should try moving code around to make sure reducers are not able to access each other states.

Previous Post Next Post