Redux in React and State Management

Luqmaan Essop / Apr 30, 2024

This article aims to share the knowledge I gained from learning Redux, and compares its pros and cons to Zustand, said to be simpler in the state management realm.

Redux in a nutshell

The goal of Redux is to increase the predictability of the state of an application. It does this by creating a store and defining a set of available actions that can modify the store in a specific way, one can only modify the store through these defined actions.

"At its core, Redux is really a fairly simple design pattern: all your 'write' logic goes into a single function, and the only way to run that logic is to give Redux a plain object that describes something that has happened. The Redux store calls that write logic function and passes in the current state tree and the descriptive object, the write logic function returns some new state tree, and the Redux store notifies any subscribers that the state tree has changed." Structuring Reducers

Reducer

The reducer is the bit of code that defines the logic for updating the store. Typically, reducers use a switch statement to match action types and execute the appropriate logic to recalculate the state without directly modifying it.

We never want to update state directly because it causes unpredictability and removes the ability to time travel backwards, so the approach before was to create copies of state and return new state objects instead. It's a lot simpler now, thanks to immer.

Before (how we needed to update state):

After (using modern redux that runs immer under the hood): Redux toolkit is the new modern way of using redux.

Store

A Store is an object that holds the application's state tree. There should only be a single store in a Redux app, as the composition happens on the reducer level.

If there is only a single store you make sure all the components that make up the application can access the same state and respond correctly to changes in that state (by connecting to the store). This makes your life easier when you need to debug and test as you only need to look into one place for the current state of your entire application.

This is the shape of the Store:

getState - returns the current state of the store. If a component is connected to the store, it can grab the current state the application is in.

subscribe - a function that allows you to register for changing values in the store, if a part of a store is updated, components / side effects can happen in return

How to subscribe to store changes:

dispatch - this is the function that accepts an action (what should be done) and then runs that particular action defined in the store. We will define which actions there are as then the store can only run a certain set of actions from a pool of actions.

The dispatch function includes an additional feature, allowing it to accept actions directly, as previously described, or to run an asynchronous task before interpreting its data and dispatching an action. This feature is especially useful in cases where we want to access the store and analyse some information before making changes to it. For example, this could involve implementing a logging system or making a third-party API call before passing the action to the reducer.

An async action is a value that is sent to a dispatching function, but is not yet ready for consumption by the reducer. It will be transformed by middleware into an action (or a series of actions) before being sent to the base dispatch() function.

Middleware

Middleware is applied to the store like this: https://redux-toolkit.js.org/api/getDefaultMiddleware

  1. What is middleware?
  • Middleware is like a middleman in Redux. It sits between the action creators and the reducers.
  • It intercepts actions before they reach the reducers.
  1. What does middleware do?
  • Middleware enhances the store's capabilities by allowing you to write logic that can intercept, analyze, modify, or dispatch actions.
  • It provides a way to perform additional tasks like logging actions, handling async operations, or routing based on the actions dispatched.
  1. How does middleware work?
  • It's like adding a layer around the dispatch function.
  • When you dispatch an action, it goes through the middleware before reaching the reducers.
  • Middleware can inspect the action, perform some logic, and then pass it along to the next middleware or the reducers.
  1. Example scenarios:
  • Logging: Middleware can log every action that is dispatched, helping with debugging and understanding how the state changes over time.
  • Async operations: Middleware can intercept async actions (like API requests), perform the async operation, and then dispatch new actions with the results.
  • Routing: Middleware can intercept certain actions (e.g. navigation actions) and handle routing based on the action type or payload.
  • Logging a value to the console
  • Saving a file
  • Setting an async timer
  • Making an AJAX HTTP request
  • Modifying some state that exists outside of a function, or mutating arguments to a function
  • Generating random numbers or unique random IDs (such as Math.random() or Date.now())
  1. Middleware composition:
  • You can chain multiple middleware together to create a pipeline for processing actions.
  • Each middleware in the chain receives the dispatch function and can modify the action or call the next middleware in the chain.

In summary, middleware in Redux is a powerful tool that allows you to extend and customise the behaviour of your store. It's flexible, composable, and can be used for various purposes like logging, handling async operations, or intercepting actions for routing.

Middleware shape / signature:

Example of custom logger middleware:

* You need to return a value from your middleware so that the middleware chain execution can continue!

Thunks

Using thunks requires the redux-thunk middleware to be added to the Redux store as part of its configuration.

Thunk is also a middleware, except someone else wrote it not you ...

Thunks are a standard approach for writing async logic in Redux apps, and are commonly used for data fetching. However, they can be used for a variety of tasks, and can contain both synchronous and asynchronous logic.

redux-thunk is the middleware that allows you to dispatch functions (thunks) instead of the predefined actions.

Writing Thunks​

A thunk function is a function that accepts two arguments: the Redux store dispatch method, and the Redux store getState method. Thunk functions are not directly called by application code. Instead, they are passed to store.dispatch():

A thunk function may contain any arbitrary logic, sync or async, and can call dispatch or getState at any time.

Dispatching thunk functions

More on Reducers

Reducers are the most important concept in Redux - they actually make the changes to the store.

Shape of the Reducer

A reducer is a function that accepts an accumulation and a value and returns a new accumulation. They are used to reduce a collection of values down to a single value.

Reducers are not unique to Redux – they are a fundamental concept in functional programming. Even most non-functional languages, like JavaScript, have a built-in API for reducing. In JavaScript, it's Array.prototype.reduce().

In Redux, the accumulated value is the state object, and the values being accumulated are actions. Reducers calculate a new state given the previous state and an action. They must be pure functions – functions that return the exact same output for given inputs. They should also be free of side effects. This is what enables exciting features like hot reloading and time travel.

Do not put API calls into reducers – that's why we have middleware.

Rules of Reducer writing:

We said earlier that reducers must always follow some special rules. These rules are actually rules that make a function a pure function and not necessarily reducer-specific rules:

  • They should only calculate the new state value based on the state and action arguments (their arguments)
  • They are not allowed to modify the existing state. Instead, they must make immutable updates, by copying the existing state and making changes to the copied values.
  • They must not do any asynchronous logic or other "side effects" – remember Middleware is for that!

But why are these rules important? There are a few different reasons:

  • When a function's output is only calculated from the input arguments, it's easier to understand how that code works, and to test it.
  • If a function depends on variables outside itself, or behaves randomly, you never know what will happen when you run it.
  • If a function modifies other values, including its arguments, that can unexpectedly change the way the application works. This can be a common source of bugs, such as "I updated my state, but now my UI isn't updating when it should!"

Summary:

  1. Redux apps use plain JS objects, arrays, and primitives as the state values
  • The root state value should be a plain JS object
  • The state should contain the smallest amount of data needed to make the app work
  • Classes, Promises, functions, and other non-plain values should not go in the Redux state
  • Reducers must not create random values like Math.random() or Date.now()
  • It's okay to have other state values that are not in the Redux store (like local component state) side-by-side with Redux
  1. Actions are plain objects with a type field that describes what happened
  • The type field should be a readable string, and is usually written as 'feature/eventName'
  • Actions may contain other values, which are typically stored in the action.payload field
  • Actions should have the smallest amount of data needed to describe what happened
  1. Reducers are functions that look like (state, action) => newState
  • Reducers must always follow special rules:
  • Only calculate the new state based on the state and action arguments
  • Never mutate the existing state - always return a copy
  • No "side effects" like AJAX calls or async logic
  1. Reducers should be split up to make them easier to read
  • Reducers are usually split based on top-level state keys or "slices" of state
  • Reducers are usually written in "slice" files, organised into "feature" folders
  • Reducers can be combined with the Redux combineReducers function
  • The key names given to combineReducers define the top-level state object keys

* First pitfall faced - redux needs global context and I'm not sure where to wrap it in the UI package

Zustand is a state management library that operates differently from Redux. Unlike Redux, which relies heavily on the concept of a centralised store and uses context providers to make the store accessible throughout the component tree, Zustand utilises a more decentralised approach.

Zustand allows you to create standalone stores using the createStore function. These stores are essentially just hooks that return state and state manipulation functions. Since each store is independent and created using a hook, you can directly import and use these hooks in your components without needing a provider.

The lack of provider wrapping in Zustand is a design choice that simplifies the setup process and reduces boilerplate. It's also possible because Zustand's stores are not tied to a global context like Redux's stores. Instead, they are simply React hooks that manage their own state internally. This makes Zustand more flexible and easier to integrate into React applications without the need for provider components.

Exercise in the real world!

After familiarising myself with Redux, I set out to compare it with Zustand by replicating an existing project that uses Zustand for state management. My goal was to recreate the same state and functionality managed by the Zustand store, but using Redux instead.

Store requirements:

The store should keep track of these values.

And provide ways of updating 2 scenarios:

The shape of the Zustand store:

In possible file location (/utils/ModalStore.ts) or as preferred for your project.

Then how to use this Zustand store in components.ts/tsx files in react:

Getting state values:

Updating state values - this is an example, please be sure to pass the correct object shapes along to the functions to match the receiving types. You will be shouted at by Typescript.

That’s it for Zustand! 🎆

The shape of the Redux store:

In possible file location (/utils/ModalStore.ts) or as preferred for your project.

Then we need some typed hooks to use the store as per Redux Typescript guide, create another file for reduxHooks in the utils folder alongside the store config file:

These will be used throughout your components to dispatch and select state values from the Redux store.

Now for the big Reducer file, where all the logic lives (create a reducers folder with example base.tsx file)

Here we used import { createAction, createReducer } from '@reduxjs/toolkit'; functions to create:

Actions - allowable methods that can be used to operate on state values of the store, we define what the action name is and what the shape of the payload(input args) it accepts.

Reducer - here we are creating a reducer which will be passed to the redux store file, the reducer accepts an initial state as its first value and then defines using the builder callback(provided by createReducer) the actions that the store would accept - then actually proceeds to do the state mutations on the stores state. (Directly because lovely createReducer also runs immer under the hood so no need for object copying and worrying about mutating state directly)

How to use the Redux store in components to read state and modify state:

First, we need to go fetch those typed hooks we created earlier.

Then we use them to gain access to the dispatch functionality (used to trigger actions to the store) / or to select state values from the store.

Remember that because Redux is built with context providers unlike Zustand, we need to wrap the component up or wrap a higher component up like a layout/frame component with the provider and pass it to the store we created in the provider given to us by Redux. We will not be able to use the Redux store in any way if we don't do this, unfortunately.

Similarities

They both have the power to mutate state directly with immer.

https://docs.pmnd.rs/zustand/integrations/immer-middleware - with the immer middleware. We did not use this in the example above though.

https://redux-toolkit.js.org/usage/immer-reducers#immutable-updates-with-immer - with createReducer or createSlice

Oh check here, Zustand actually used context before this.

Conclusion

Redux typescript was not easy to write - here is a guide if you are brave, getting the type inference to work correctly was challenging. I swapped from the createSlice method of setting up the Redux store to using the createReducer method instead as it inferred the type of the actions better. Which meant calling the dispatch method inside components gave me autocompletion hints, I could see exactly what the reducer expects to be passed in.

Redux does not have built-in localStorage configurations tied into the store - see the note on handling initial state of the store. https://redux-toolkit.js.org/api/createSlice/#initialstate I quite liked this from Zustand.

In theory, we would probably read in localStorage data before configuring the store and then write some middleware to update the localStore before calling any store action to update the state, I just did not get there yet…

Zustand was super simple to set up and because it is built on hooks one can just import the store from any component and check any point of state in the store. Zustand can also create multiple different stores and they don't overlap (although I’m not sure how necessary this is). Zustand looks like regular javascript with properties that are the actual values on the store we want to keep track of and at the same time holds the methods in the store that handles the logic to update the store - everything is encapsulated as a whole.

Zustand has a built-in functionality that can persist the state in localstorage when you configure the store whereas Redux does not have this built in.

We can refactor our Zustand store a little to make the store look a little more like Redux, where the store only holds the values and the methods are outside (nice separation of concerns), or it doesn’t even matter if you prefer co-locating everything store related as left before.

I even see the possibility of writing custom middleware like we can do in Redux for Zustand here, allowing you to wrap the store functionality in some wrapper functions that can catch store updates before they happen and make store updates outside of the defined methods provided by the store.

One thing to note. Storybook is the go-to for frontend developers to develop UI interfaces, for your store to actually work in storybook using Zustand you simply use the hooks to the Zustand store and that's it.

On the other hand with Redux, you would have to provide the store provider as a decorator to the story in order for you to be able to use it in storybook. I needed this to trigger the visibility of a modal from the storybook level in order to write appropriate tests to test its visibility or not.

Both Redux and Zustand support slicing up the store into separate concerns when things get too big and unmaintainable.

Eg: authors, books, users etc. so they are split and handled separately.

https://docs.pmnd.rs/zustand/guides/slices-pattern

https://redux-toolkit.js.org/api/createSlice

* This is vital tooling for inspecting store state and actions as you debug your application while building up your store, trust me. You will see how we can time travel through state too because we don't mutate state directly.

Redux devtools - https://chromewebstore.google.com/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd

Screenshot

Zustand devtools - https://chromewebstore.google.com/detail/zukeeper-devtools-for-zus/copnnlbbmgdflldkbnemmccblmgcnlmo

These addons are easy to set up and very much required when developing. This takes all the guesswork out of the state the store is currently holding while you are debugging making sure everything works and updates correctly as you go through each action done to the store, what action type was triggered and what data was inside the payload that went along with the action, then you can inspect the resulting stores state after operation.

If you want to learn more about immer and why to use it, give this a read. It uses the power of some special proxy object to keep track of attempts at mutable state updates and then does it behind the scenes in a safer manner (immutably).

Final thoughts

Redux was said to be more complex tbh and I see that, but I see the wisdom behind splitting things up as well (Store, actions, reducers, middleware, slices) it’s a lot but it makes sense.

Zustand I believe is ideal if you need a simple state management setup, you saw the code above. If we want simple, we can get there very quickly as opposed to creating a ‘simple' Redux store - that doesn't even sound right with all the 'actions, reducers, middleware’ talk we went through.

That's all for now. I hope this has clarified the role of state management solutions in React projects, whether you're working at the component level or with Storybook. This should provide a solid foundation for you to explore the various state management solutions available and how they can integrate into your projects.

If you have any questions or need further guidance, feel free to reach out – we'd be happy to help!