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.
Comments
Add your honest opinion about this article and help us improve the content.
10/10