DEV Community

Cover image for Redux immutable update patterns
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Redux immutable update patterns

Written by Kasra Khosravi✏️

I think one of the main reasons you are reading an article about Redux is that the application you are working on is in a growing phase and might be getting more complicated each day. You are getting new business logic requirements that require you to handle different domains and need a consistent and debuggable way of handling application state.

If you are a single developer working on a simple app (or have just started to learn a new frontend framework like React, which we use as an example in this article), I bet you might not need Redux. Unless you are approaching this as a learning opportunity.

Redux makes your application more complicated, but that is a good thing. This complexity brings simplicity for state management at scale.

  • When you have few isolated components that do not need to talk to each other and want to maintain simple UI or business logic, by all means, use local state
  • If you have several components that need to subscribe to get the same type of data and in reaction, dispatch a notification, change or event loaders might be your best friend
  • However, if you have several components (as shown in the image below) that do need to share some sort of state with other components without a direct child-parent relationship, then Redux is a perfect solution

Without Redux, each of the components needed to pass state in some form to other components that might need it and handle command or event dispatching in reaction to that. It easily becomes a nightmare to maintain, test, and debug such a system at scale. However, with the help of Redux, none of the components need to hold any logic about managing state inside them. All they have to do is to subscribe to Redux to get the state they need and dispatch actions to it in return if needed.

graph of components initiating change with and without redux
https://blog.codecentric.de

The core part of Redux that enables state management is store, which holds the logic of your application as a state object. This object exposes few methods that enable getting, updating, and listening to state and its changes. In this article, we will solely focus on updating the state. This is done using the dispatch(action) method. This is the only way to modify the state which happens in this form.

The store’s reducing function will be called with the current getState() result and the given action synchronously. Its return value will be considered the next state. It will be returned from getState() from now on, and the change listeners will immediately be notified

The primary thing to remember is that any update to the state should happen in an immutable way. But why?

LogRocket Free Trial Banner

Why immutable update?

Let’s imagine you are working on an e-commerce application with this initial state:

const initialState = {
  isInitiallyLoaded: false,
  outfits: [],
  filters: {
    brand: [],
    colour: [],
  },
  error: '',
};
Enter fullscreen mode Exit fullscreen mode

We have all sorts of data types here — string , boolean , array, and object. In response to application events, these state object params need to be updated, but in an immutable way. In other words:

The original state or its params will not be changed (or mutated); but new values need to be returned by making copies of original values and modifying them instead.

In JavaScript:

  • strings and booleans (as well as other primitives like number or symbol) are immutable by default. Here is an example of immutability for strings:
// strings are immutable by default

// for example when you define a variable like:
var myString = 'sun';

// and want to change one of its characters (string are handled like Array):
myString[0] = 'r';

// you see that this is not possible due to the immutability of strings
console.log(myString); // 'sun'

// also if you have two references to the same string, changing one does not affect the other
var firstString = secondString = "sun";

firstString = firstString + 'shine';
console.log(firstString); // 'sunshine'
console.log(secondString); // 'sun'
Enter fullscreen mode Exit fullscreen mode
  • objects are mutable, but can be freezed:

In the example below, we see this in action. We also see that when we create a new object by pointing it to an existing object and then mutating a properties on the new object, this will result in a change in properties on both of them:

'use strict';

// setting myObject to a `const` will not prevent mutation.
const myObject = {};
myObject.mutated = true;
console.log(myObject.mutated); // true

// Object.freeze(obj) to prevent re-assigning properties, 
// but only at top level
Object.freeze(myObject);
myObject.mutated = true;
console.log(myObject.mutated); // undefined

// example of mutating an object properties
let outfit = {
    brand: "Zara",
    color: "White",
    dimensions: {
        height: 120,
        width: 40,
    }
}

// we want a mechanism to attach price to outfits
function outfitWithPricing(outfit) {
    outfit.price = 200;
    return outfit;
}

console.log(outfit); // has no price

let anotherOutfit = outfitWithPricing(outfit);

// there is another similar outfit that we want to have pricing.
// now outfitWithPricing has changed the properties of both objects.
console.log(outfit); // has price
console.log(anotherOutfit); // has price

// even though the internals of the object has changed, 
// they are both still pointing to the same reference
console.log(outfit === anotherOutfit); // true
Enter fullscreen mode Exit fullscreen mode

If we want to accomplish immutable update to object, we have few options like using Object.assign or spread operator:

// lets do this change in an immutable way
// we use spread oeprator and Object.assign for 
// this purpose. we need to refactor outfitWithPricing
// not to mutate the input object and instead return a new one
function outfitWithPricing(outfit) {
  let newOutfit = Object.assign({}, outfit, {
    price: 200
  })

  return newOutfit;
}

function outfitWithPricing(outfit) {
  return {
    ...outfit,
    price: 200,
  }
}

let anotherOutfit = outfitWithPricing(outfit);
console.log(outfit); // does not have price
console.log(anotherOutfit); // has price

// these two objects no longer point to the same reference
console.log(outfit === anotherOutfit); // false
Enter fullscreen mode Exit fullscreen mode
  • arrays have both mutable and immutable methods:

It is important to keep in mind which array methods are which. Here are few cases:

  • Immutable methods: concat, filter, map, reduce, reduceRight, and reduceRight
  • Mutable methods: push, pop, shift, unshift, sort, reverse, splice and delete

Keep in mind that spread operator is applicable for array as well and can make immutable updates much easier. Let’s see some mutable and immutable updates as an example:

// The push() method adds one or more elements to the end of an array and returns
// the new length of the array.
const colors = ['red', 'blue', 'green'];

// setting a new varialbe to point to the original one
const newColors = colors;
colors.push('yellow'); // returns new length of array which is 4
console.log(colors); // Array ["red", "blue", "green", "yellow"]

// newColors has also been mutated
console.log(newColors); // Array ["red", "blue", "green", "yellow"]

// we can use one of the immutable methods to prevent this issue
let colors = ['red', 'blue', 'green'];
const newColors = colors;

// our immutable examples will be based on spread operator and concat method
colors = [...colors, 'yellow'];
colors = [].concat(colors, 'purple');

console.log(colors); // Array ["red", "blue", "green", "yellow", "purple"]
console.log(newColors); // Array ["red", "blue", "green"]
Enter fullscreen mode Exit fullscreen mode

So in a real-life example, if we need to update the error property on state, we need to dispatch an action to the reducer. Redux reducers are pure functions, meaning that:

  • They always return the same value, based on the same input (which is the state and action)
  • They do not perform any side effects like making API calls

This requires us to handle state updates in reducers in an immutable way, which has several advantages:

  • Easier testing of reducers, since the input and output are always predictable
  • Debugging and time travel, so you can see the history of changes rather than only the outcome

But the biggest advantage of all would be to protect our application from having rendering issues.

In a framework like React which depends on state to update the virtual DOM, having a correct state is a must. In this way, React can realize if state has changed by comparing references (which has Big O Notation of 1 meaning much faster), rather than recursively comparing objects (which is slower with a Big Notation of n).

big O complexity chart

After we dispatch the HANDLE_ERROR action, notifying the reducer that we need to update the state, here is what happens:

  • As the first step, it uses the spread operator to make a copy of stat object
  • As the second step, it has to update the error property and return the new state
  • All the components that are subscribed to store get notified about this new state and re-render if needed
// initial state
const initialState = {
  isInitiallyLoaded: false,
  outfits: [],
  filters: {
    brand: [],
    colour: [],
  },
  error: '',
};

/**
 * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state.
 */
function handleError(state = initialState, action) {
    if (action.type === 'HANDLE_ERROR') {
      return {
          ...state,
          error: action.payload,
      } // note that a reducer MUST return a value
    }
}

// in one of your components ...

store.dispatch({ type: 'HANDLE_ERROR', payload: error }) // dispatch an action that causes the reducer to execute and handle error
Enter fullscreen mode Exit fullscreen mode

So far, we have covered the basics of Redux’s update patterns in an immutable way. However, there are some types of updates that can be trickier than others like removing or updating nested data. Let’s cover some of these cases together:

Adding items in arrays

As mentioned before, several array methods like unshift , push , and splice are mutable. We want to stay away from them if we are updating the array in place.

Whether we want to add the item to the start or end of array, we can simply use the spread operator to return a new array with the added item. If we intend to add the item at a certain index, we can use splice, as long as we make a copy of the state first then it will be safe to mutate any of the properties:

// ducks/outfits (Parent)

// types
export const NAME = `@outfitsData`;
export const PREPEND_OUTFIT = `${NAME}/PREPEND_OUTFIT`;
export const APPEND_OUTFIT = `${NAME}/APPEND_OUTFIT`;
export const INSERT_ITEM = `${NAME}/INSERT_ITEM`;

// initialization
const initialState = {
  isInitiallyLoaded: false,
  outfits: [],
  filters: {
    brand: [],
    colour: [],
  },
  error: '',
};

// action creators
export function prependOutfit(outfit) {
    return {
      type: PREPEND_OUTFIT,
      outfit
    };
}

export function appendOutfit(outfit) {
    return {
      type: APPEND_OUTFIT,
      outfit
    };
}

export function insertItem({ outfit, index }) {
    return {
      type: INSERT_ITEM,
      outfit,
      index,
    };
}

// immutability helpers
function insertItemImHelper(array, action) {
  let newArray = array.slice()
  newArray.splice(action.index, 0, action.item)
  return newArray
}


export default function reducer(state = initialState, action = {}) {
  switch (action.type) {  
  case PREPEND_OUTFIT:
    return {
      ...state,
      outfits: [
        action.payload,
        ...state.outfits,
      ]
    };
  case APPEND_OUTFIT:
    return {
      ...state,
      outfits: [
        ...state.outfits,
        action.payload,
      ]
    };
  case INSERT_ITEM:
    return {
      ...state,
      outfits: insertItemImHelper(state.outfits, action)
    };
  default:
     return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding items in arrays within a nested object

Updating nested data gets a bit trickier. The main thing to remember for update in nested properties is to correctly update every level of data and perform the update correctly. Let’s see an example for adding an item to an array which is located in a nested object:

// ducks/outfits (Parent)

// types
export const NAME = `@outfitsData`;
export const ADD_FILTER = `${NAME}/ADD_FILTER`;

// initialization
const initialState = {
  isInitiallyLoaded: false,
  outfits: [],
  filters: {
    brand: [],
    colour: [],
  },
  error: '',
};

// action creators
export function addFilter({ field, filter }) {
    return {
      type: ADD_FILTER,
      field,
      filter,
    };
}

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {  
  case ADD_FILTER:
  return {
    ...state,
    filters: {
    ...state.filters,
       [action.field]: [
         ...state.filters[action.field],
         action.filter,
       ]
    },
  };
  default:
     return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Removing items in arrays

Removing items in an immutable way can be performed in several ways. For example, we can use an immutable method like filter, which returns a new array:

function removeItemFiter(array, action) {
  return array.filter((item, index) => index !== action.index)
}
Enter fullscreen mode Exit fullscreen mode

Or we can make a copy of the array first, and then use splice to remove an item in a certain index within the array:

function removeItemSplice(array, action) {
  let newArray = array.slice()
  newArray.splice(action.index, 1)
  return newArray
}
Enter fullscreen mode Exit fullscreen mode

Here is an example to show these immutability concepts being used in the reducer to return the correct state:

// ducks/outfits (Parent)

// types
export const NAME = `@outfitsData`;
export const REMOVE_OUTFIT_SPLICE = `${NAME}/REMOVE_OUTFIT_SPLICE`;
export const REMOVE_OUTFIT_FILTER = `${NAME}/REMOVE_OUTFIT_FILTER`;

// initialization
const initialState = {
  isInitiallyLoaded: false,
  outfits: [],
  filters: {
    brand: [],
    colour: [],
  },
  error: '',
};

// action creators
export function removeOutfitSplice({ index }) {
    return {
      type: REMOVE_OUTFIT_SPLICE,
      index,
    };
}

export function removeOutfitFilter({ index }) {
    return {
      type: REMOVE_OUTFIT_FILTER,
      index,
    };
}

// immutability helpers
function removeItemSplice(array, action) {
  let newArray = array.slice()
  newArray.splice(action.index, 1)
  return newArray
}

function removeItemFiter(array, action) {
  return array.filter((item, index) => index !== action.index)
}

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {  
  case REMOVE_OUTFIT_SPLICE:
    return {
      ...state,
      outfits: removeItemSplice(state.outfits, action)
    };
  case REMOVE_OUTFIT_FILTER:
    return {
      ...state,
      outfits: removeItemFiter(state.outfits, action)
    };
  default:
     return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Removing items in arrays within a nested object

And finally we get to removing an item in an array which is located in a nested object. It is very similar to adding an item, but in this one, we are going to filter out the item in the nested data:

// ducks/outfits (Parent)

// types
export const NAME = `@outfitsData`;
export const REMOVE_FILTER = `${NAME}/REMOVE_FILTER`;

// initialization
const initialState = {
  isInitiallyLoaded: false,
  outfits: ['Outfit.1', 'Outfit.2'],
  filters: {
    brand: [],
    colour: [],
  },
  error: '',
};

// action creators
export function removeFilter({ field, index }) {
  return {
    type: REMOVE_FILTER,
    field,
    index,
  };
}

export default function reducer(state = initialState, action = {}) {
  sswitch (action.type) {  
  case REMOVE_FILTER:
  return {
    ...state,
    filters: {
    ...state.filters,
       [action.field]: [...state.filters[action.field]]
       .filter((x, index) => index !== action.index)
    },
  };
  default:
     return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Lets review what have we learned together:

  • Why and when we might need a state management tool like Redux
  • How Redux state management and updates work
  • Why immutable update is important
  • How to handle tricky updates like adding or removing items in nested objects

Please use the below references list to get more info on this topic. We intended to learn the basics of manual immutable update patterns in Redux in this article. However, there are a set of immutable libraries like ImmutableJS or Immer, that can make your state updates less verbose and more predictable.

References


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

Alt Text

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.


The post Redux immutable update patterns appeared first on LogRocket Blog.

Top comments (0)