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!
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:
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.
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.
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.
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.
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:
concat
, filter
, map
, reduce
, and reduceRight
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:
state
and action
These properties require us to handle state updates in reducers in an immutable way, which brings several advantages:
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
:
After we dispatch the HANDLE_ERROR
action, notifying the reducer that we need to update the state, the following will occur:
spread operator
to make a copy of the stat
objecterror
property and returns the new state// 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.
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; } }
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; } }
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; } }
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; } }
In this article, we covered the following:
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.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowconsole.time is not a function
errorExplore the two variants of the `console.time is not a function` error, their possible causes, and how to debug.
jQuery 4 proves that jQuery’s time is over for web developers. Here are some ways to avoid jQuery and decrease your web bundle size.
See how to implement a single and multilevel dropdown menu in your React project to make your nav bars more dynamic and user-friendly.
NAPI-RS is a great module-building tool for image resizing, cryptography, and more. Learn how to use it with Rust and Node.js.