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
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)
All reducers are finally merged into one
The subscribe function works for the entire store
Monolith structure - like an extensive database
Requires a wrapper (StoreProvider) - something that will integrate Redux with React
Action creators - functions that create action objects
Hooks for reading and modifying the state
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
There is no dispatch function
It has middleware for each store separately
Has a subscription function per store
0 wrappers required
More than one store
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.
Now it's time for a slice that will generate reducers and actions for us.
Finally, all reducers must be merged into one - reducers.ts file.
And we still need the store.ts file in which we will connect everything.
Now the code in the component.
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!
Now just import the hook created by Zustand to any component and use the state or action.
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.
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.
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.
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.
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.
And this is how we should type a regular actions.
And finally, typing the state in the slice.
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.
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:
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:
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
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.
Implementation of own middleware in Redux toolkit
This code will catch the exception in the code and send the logs to the database.
Implementation of own middleware in Zustand
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.
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.
Now in the main application file app.tsx we need to add a wrapper - PersistGate.
Data persistence in Zustand
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.
And now the component code:
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:
And in the component, we do this:
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.
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.
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.
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.
And now an example of use when testing a component:
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.
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.
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.
And how to add Immer? If needed, just use the following middleware.
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.
Working with actions in Zustand
Here, it is enough to declare an action, import it and call it.
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:
Reading the current state looks similar:
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.
Now we need to be able to add a new slice.
Now we can call it anywhere:
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.
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.
In Zustand, each store is independent of each other. So there will be no additional comparisons.
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.
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
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)