Redux immutable update patterns

9 min read 2603

Redux Immutable Update Patterns

Editor’s note: This post was last updated 30 December 2021 to include information about Redux Toolkit.

Since you’re reading an article about Redux, I assume the project you’re working on is in a growing phase and might be growing more complicated with each day. You’re probably getting new business logic requirements, meaning you need a consistent and debuggable way of handling application state to handle different domains.

If you’re a single developer working on a simple app, or you’ve just started to learn a new frontend framework like React, you might not need Redux, unless you’re approaching this as a learning opportunity.

Although Redux makes your application more complicated, this complexity brings simplicity for state management at scale. In this article, we’ll explore this concept by looking into Redux’s immutable update patterns. Let’s get started!

Table of contents

Redux’s inner workings

When you have few isolated components that do not need to talk to each other, and you want to maintain a simple UI or business logic, 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 that need to share some sort of state with other components without a direct child-parent relationship, like in the image below, then Redux is a perfect solution.

Without Redux, each component needs to pass state in some form to other components that need it, handling command or event dispatching in reaction. You can see how this system would easily becoming nightmarish to maintain, test, and debug at scale.

However, with the help of Redux, none of the components need to hold any logic for managing state. Instead, they have to subscribe to Redux to get the state they need and dispatch actions to it in return when needed:

Redux Subscribe Components
Graph of components initiating change with and without Redux

https://blog.codecentric.de

The core part of Redux that enables state management is the Redux store, which holds the logic of your application as a state object. The state object exposes a few methods that enable getting, updating, and listening to the state and its changes.

We made a custom demo for .
No really. Click here to check it out.

In this article, we’ll focus solely on updating the state using the dispatch(action) method. The dispatch(action) method is the only way to modify the state, occurring in this form.

The store’s reducing function will be called synchronously with the current getState() result and the given action. 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.

Redux Toolkit

Note: At the time of updating this post, most developers use Redux Toolkit to easily set up Redux in their React applications.

With Redux Toolkit, we don’t have to worry about immutability. Although it may seem like the state is mutable while working with Redux Toolkit, it actually uses Immer behind the scenes to make sure that the state is immutable, making state management easier.

Redux Toolkit uses slices to represent a piece of data; let’s look at an example slice:

const locationSlice = createSlice({
  name: 'location',
  initialState: {x: 0, y: 0},
  reducers: {
    setXCoord: (state, action) => {
      state.x = action.payload;
    },
    setYCoord: (state, action) => {
      state.y = action.payload;
    },
  },
})

The Redux Toolkit slices above also contain the reducer functions. Here, we have two functions, setXCoord and setYCoord. Both seem to mutate the state directly, but it’s not because of Immer, which is being used in Redux Toolkit behind the scenes.

Let’s look at a to-do list and a scenario of updating an array:

const todoSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: (state, action) => {
      state.push(action.payload)
    }
  },
})

We are trying to use push(), which mutates the array, as we will discuss later in the article. Here, we are using push() on a copy of the state, and Redux Toolkit ensures that the state is updated properly.

If you still want to set up Redux manually, you might need to deal with immutability yourself. Further in this article, we’ll see why immutability is necessary and how to handle it.

Why use an immutable update pattern?

Let’s imagine you’re working on an ecommerce application with the following initial state:

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

We have all sorts of data types here, including string , boolean , array, and object. In response to application events, these state object params need to be updated in an immutable way. In other words, the original state or its params will not be changed or mutated. Instead, we’ll make copies of original values, modifying them to return new values.

Immutability 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'

objects are mutable, but can be frozen. 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, then mutating a property on the new object, it results 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

If we want to accomplish an immutable update to object, we have few options, like 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

arrays have both mutable and immutable methods. It’s important to keep in mind which array methods are which. Here are few cases:

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

Keep in mind that spread operator is applicable for the 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"]

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:

  • 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

These properties require us to handle state updates in reducers in an immutable way, which brings 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

The biggest advantage is protecting our application from having rendering issues.
In a framework like React, which depends on state to update the virtual DOM, having the correct state is a must.

React can realize if state has changed by comparing references, which has Big-O Notation of 1, meaning faster, rather than recursively comparing objects, which is slower, indicated by a Big-O Notation of n:

Big O Complexity Chart
Big-O complexity chart

After we dispatch the HANDLE_ERROR action, notifying the reducer that we need to update the state, the following will occur:

  • First, it uses the spread operator to make a copy of the stat object
  • Next, it updates the error property and returns the new state
  • All the components that are subscribed to the store get notified about this new state, re-rendering 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

We’ve 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.

Adding items in arrays

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

Whether we want to add the item to the start or the end of the 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;
  }
}

Adding items in arrays within a nested object

Updating nested data gets a bit trickier. Remember to correctly update every level of data to perform the update correctly. Let’s see an example of 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;
  }
}

Removing items in arrays

There are several ways to remove items in an immutable way. For one, 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)
}

Or, we can make a copy of the array first, 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
}

The example below shows 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;
  }
}

Removing items in arrays within a nested object

Finally, we’ll remove an item in an array, which is located in a nested object. It is very similar to adding an item, but instead, we’ll 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;
  }
}

Conclusion

In this article, we covered the following:

  • Why and when we need a state management tool like Redux
  • How Redux state management and updates work
  • How Redux Toolkit handles immutability
  • Why immutable updates are important
  • How to handle tricky updates, like adding or removing items in nested objects

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.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard 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.

LogRocket is like a DVR for web and mobile 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 — .

Leave a Reply