10/10

Article thumbnail

🥇 Comparing Redux with Zustand for state management in React

10m
state
libraries
comparision

Intro

Deep and detailed dive into Redux Toolkit and Zustand. Let's understand the key differences between them and their similarities.

Choosing a library for state management in React

This is a very problematic and complicated topic. For a long time, I maneuvered between the Redux toolkit and ContextAPI. After a while, I always came to the same question - is there an alternative? There are many of them and if I had to list them, it would take me two days.

I was looking for something straightforward - producing a small boilerplate and supporting a modular approach - because it keeps the application size small and easy to maintain. And I found it!

Today we will compare the capabilities of Zustand and Redux toolkit.

Redux core concepts

  • One store
  • Changes in the store done via dispatch function
  • Action should be passed to the dispatch function (an object with a unique key and additional data)
  • Implements the CQRS pattern
  • It is based on the FLUX architecture (modifies it - instead of many stores we have one)
  • Implements reducers (they check the type of the given action and change it to a state based on the passed data)
  • Has middleware
  • All reducers are finally merged into one
  • The subscribe function works for the entire store
  • Has selectors
  • Monolith structure - like an extensive database
  • No modularity
  • Requires a wrapper (StoreProvider) - something that will integrate Redux with React

Elements:

  • Store (one)
  • Reducer
  • Middleware
  • Actions
  • Action creators - functions that create action objects
  • Selectors
  • Hooks for reading and modifying the state
  • Dispatcher
  • Slice (in the case of Redux toolkit)
  • Acts (in the case of Redux toolkit)

Zustand core concepts

  • More than one store
  • We make changes in the store using actions - a regular function
  • Implements the FLUX architecture
  • No reducers
  • There is no dispatch function
  • No CQRS
  • It has middleware for each store separately
  • Modularity
  • Has a subscription function per store
  • Has selectors
  • 0 wrappers required

Elements:

  • More than one store
  • Actions
  • Selectors
  • Middleware - per store
  • Hook for invoking actions and reading state (one)

Setup for the Redux toolkit

To start with Redux toolkit and React we need to import created store and pass if through Provider.

Loading

Now it's time for a slice that will generate reducers and actions for us.

Loading

Finally, all reducers must be merged into one - reducers.ts file.

Loading

And we still need the store.ts file in which we will connect everything.

Loading

Now the code in the component.

Loading

I don't know about you, but I see a lot of code that will get even more complicated with each new feature.

Setup for Zustand

We need a store. That's all! It will have both a state and an actions. Note that usePostsStore is a hook!

Loading

Now just import the hook created by Zustand to any component and use the state or action.

Loading

Note how relatively short it took to get exactly the same effect as in Redux. It was just one file!

Libraries size difference

On the Bundlephobia website, the result for Redux, React redux, Redux toolkit and Redux thunk is 1.8kB + 4.9kB + 3.6kB + 236B = ~10,537kB.

For Zustand it is 1.1kB. For slow 3G it will be:

Redux = ~223ms

Zustand = 23ms

Zustand is a library that does not have any additional dependencies and will not require them to get the same functionality that the Redux ecosystem offers (four listed libraries - see above).

Boilerplate and application size

The size of the libraries is one thing, but the impact on the application size of the code we have to write in them is also important. For the implementation of the functionality with posts (see above), the application sizes (for the production configuration) are:

Redux = 126kB (pure project + feature posts).

Zustand = 117KB (pure project + feature posts).

Now 30 functionalities with identical implementation.

Redux = 30 (number of features) * 0.97kB (boilerplate size per feature) = 29,1kB.

Zustand = 30 (number of features) * 0.44kB (boilerplate size per feature) = 13.2kB.

So finally:

Redux = 126kB (initial size) + 29,1kB (functional size 30) = 155,1kB.

Zustand = 117KB (initial size) + 13.2kB (functional size 30) = 130,2kB.

Temporarily for slow 3G:

Redux = ~3 456ms

Zustand = ~2 795ms

Asynchronous operations in the Redux toolkit

If we use redux-thunk, we handle asynchronous operations in the act.

Loading

Now in our slice, we can handle state changes. The Redux toolkit will automatically generate the appropriate actions - pending, fulfilled and rejected, in which we must define how to change the state.

Loading

It is worth noting that the state change is separated from the API call logic. In act we define what to call and what to return, and in slice, we handle how to change the state based on the result.

Asynchronous operations in Zustand

In Zustand, we handle asynchronous operations with a regular function with an async annotation.

Loading

Zustand uses an approach in which the modification of values and their reading is in one place - which means that there will be less code.

Type safety in the Redux toolkit

In the Redux toolkit, types are inferred for act based on the return values and the explicit typing of the parameters passed.

Loading

And this is how we should type a regular actions.

Loading

And finally, typing the state in the slice.

Loading

In the Redux toolkit, types are partially deduced, so when we change implementation, we also change type definitions for actions called by act. At the same time, however, we need to create an interface for the state definition and use PayloadAction to define the payload for a specific action, which is a bit confusing.

Type safety in Zustand

In Zustand we type everything with single interface.

Loading

In Zustand thanks to the overt definition of the interface, we are guaranteed that the definition of types has a single source of truth. If we change the interface, we know that the implementation needs to be adapted.

Redux toolkit effort analysis

For each new functionality we need to:

  • Implement slice
  • Create type
  • Implement acts
  • Implement actions
  • Create a file with re-export actions from a slice
  • Create a file with re-export reducers from a slice
  • Modify the store
  • Work on integration with components
  • Create tests for each layer or an integration test
  • Switch between multiple files multiple times

In case of any change in the business logic or the name of something that the components refer to, we have to go through many files and verify the changes.

Work effort analysis at Zustand

For each new functionality we need to:

  • Create types
  • Implement store (state + actions)
  • Integrate with components
  • Write unit tests or integration test
  • Switch between two or three files (max)

In Zustand we operate mainly on the store file and everything we need is there.

Redux-devtools-extension implementation in Redux toolkit

Loading

Redux-devtools-extension implementation in Zustand

Redux devtools in Zustand? After all, it's not Redux... It doesn't change the fact that we have such a possibility. Action titles in devtools will be generated based on their names. It is also worth noting that for each store we have to connect devtools separately - Zustand is modular.

Loading

Implementation of own middleware in Redux toolkit

This code will catch the exception in the code and send the logs to the database.

Loading

Implementation of own middleware in Zustand

Loading

In the case of Zustand, we must explicitly use the middleware for each store - see line 25. To avoid code duplication, we can create a factory that will take care of creating a store that will immediately have the middleware attached.

Loading

We can use the same approach with the devtools we saw earlier.

Read the article on mocking factories to learn more about this concept.

Data persistence in the Redux toolkit

To save the content of the store to local/session storage we need to add a library. Install the package: npm i --legacy-peer-deps --save redux-persist. Another +3kB.

Loading

Now in the main application file app.tsx we need to add a wrapper - PersistGate.

Loading

Data persistence in Zustand

Loading

If you want to learn more about working with local/session storage in an easy way, I invite you to the article: Working with local storage vs session storage.

Memoization and selectors in the Redux toolkit

Selectors are functions that are designed to check whether a value or reference for a given fragment of the store has really changed. Then we can return this value or perform additional operations - mapping to another data format. The result of selectors can be remembered for specific arguments. If they have not changed, the old value will be returned - thanks to this, React will not re-render.

Loading

And now the component code:

Loading

I don't know if you noticed, but we installed another library - reselect, which increases the size of our application by another 1.3kB.

Memoization and selectors in Zustand

Just use the created store like this:

Loading

And in the component, we do this:

Loading

In addition, we can further improve it by creating a mechanism for creating selectors automatically. This is not the topic of this article, so I only mention it. You can find more about this delicacy under this codesandbox. We can create a similar mechanism for Redux.

Loading

Testing with the Redux toolkit

First, we need to start with something that will allow us to mock the store. The createStore function below will allow us to create it and give us the ability to overwrite its value per test. On the other hand, the renderWithStore function will connect any component to Redux ecosystem.

Loading

Now let's assume that we want to test how our PostsList component works, which uses useAppSelector and useAppDispatch inside to display the list of posts and to add a post.

Loading

If you are interested in topics related to testing, I invite you to read the React testing spellbook course.

Testing in Zustand

After each test, we must have a mechanism to restore the initial state. Otherwise, when it is not there, in the next tests we will have the previously set state - which is dangerous for the stability of the tests.

Loading

And now an example of use when testing a component:

Loading

State management in the Redux toolkit

The Redux toolkit uses Immer.js so that we can change the state in an easy and fun way.

Loading

However, sometimes we want to reset the state or partially replace it. Unfortunately, the Redux toolkit does not allow this. Immer.js behaviour cannot be disabled. If we want to achieve this effect, we need some additional mechanism that we have to write ourselves.

Loading

State management in Zustand

In Zustand we have a choice. We can replace the state completely or merge the new state with the current one.

Loading

And how to add Immer? If needed, just use the following middleware.

Loading

Working with actions in the Redux toolkit

Sometimes we want to trigger an action outside of the React ecosystem. In the Redux toolkit, we need to import the entire store and action and call dispatch.

Loading

Working with actions in Zustand

Here, it is enough to declare an action, import it and call it.

Loading

State reading in Redux toolkit and Zustand

To listen for state changes in both the Redux toolkit and Zustand, we can use the following API:

Loading

Reading the current state looks similar:

Loading

You can't see the difference in the API, but there is a dramatic difference in the way it works. Redux will call a callback subscribe when any state change is triggered, while Zustand will call only when this one particular store changes.

Similarly for getState. Redux will return everything (all application states), and Zustand will only return the store we are referring to.

Code splitting and lazy loading in the Redux toolkit

Code splitting and lazy loading are two popular techniques to reduce the time spent on the "first" loading of any application. What's the point of loading code for admin functionality if the user is not an admin?

In the Redux toolkit, at the very beginning, we must have a mechanism to replace the main reducer - we already have it in the library.

Loading

Now we need to be able to add a new slice.

Loading

Now we can call it anywhere:

Loading

You can find more about this in the documentation.

Code splitting and lazy loading in Zustand

Due to the modular nature, code splitting and lazy loading, we have practically out of the box. Thanks to this, both the React and Zustand code will be in one file, and we can completely separate it from the main index.js file, necessary for the application to work, which will be loaded at the beginning. Additionally, it is predictable in behaviour.

Loading

Analyze runtime performance

In the Redux toolkit, we use the ACTION_TYPE: FUNCTION object, which looks elegant, but we still call each reducer on each action.

Loading

In Zustand, each store is independent of each other. So there will be no additional comparisons.

Loading

Now, when we have 30 reducers for the application, with Redux we will call 29 additional checks for each reducer when any action is dispatched. Is this a problem? Honestly, it's not... I'm just mentioning it. The operation of finding action itself is very fast, so there is nothing to worry about...

What's better? Redux or Zustand?

Every day I realize that simpler is better. I say this because people at different levels work on projects. Redux can be complicated and it's very easy to just break on it. The concept itself is great, but in my opinion, Zustand implements it through a simpler API and introduces new possibilities - more than one store.

Zustand also abandons the concept of CQRS (command query responsibility segregation), in which data reading is separated from the way of invoking changes on them - which significantly reduces coupling and potential refactors will be simpler, as well as code maintenance - at least in theory.

Loading

Loading

In practice though, the complexity of Redux results in total noodles, and I've seen this happen in many projects. As a result, the advantages and concepts it introduces cease to be a "game changer", and are just a problem - because they are hard to understand.

I'm not discouraging you from Redux, but I do want to emphasize the question. Do you really need CQRS on the frontend, and does the extra complexity that Redux introduces give you anything?

As you have probably seen, there are many differences between the discussed technologies. I leave the decision to You, Dear Readers. Here are some reasons why I use Zustand instead of Redux:

  • Smaller package size and smaller code base
  • Modularity
  • Same capabilities as Redux (without CQRS and with multiple stores)
  • Less code needed to write
  • Code is more readable and easier to understand for beginners
  • Code splitting is really easy
  • Devtools like in Redux
  • No wrapper (StoreProvider)

Conclusions and Thoughts

Having traversed a substantial journey, you've now gained a comprehensive understanding of the pivotal distinctions among the mentioned technologies. Armed with insights into my arguments and opinions, it's now opportune for you to formulate your own perspective. A crucial reminder underscores the significance of personally exploring the technology before integrating it into your workflow - empowering you to make informed decisions based on firsthand experience.

If you want dive through selectors concept, I recommend to read ⭐ Working with selectors in Zustand and Redux article.

I create content regularly!

I hope you found my post interesting. If so, maybe you'll take a peek at my LinkedIn, where I publish posts daily.

Comments

Add your honest opinion about this article and help us improve the content.

10/10

created: 16-07-2023
updated: 01-12-2023