Updated April 7, 2022
Comparing Redux, Zustand, and mobx-state-tree
In this post we'll look at Zustand, Redux, and Mobx State Tree in the context of a simple shopping cart and compare how they allow you to store, update, and read data in a React Native app.
I'll be honest - I've been in a bit of a rut as of late with development. I've been building a lot of the same stuff, using the same tools, with the same strategies. Over time I've tried different state management solutions but in the last year or so I've been using Zustand nearly exclusively.
So, in an effort to refamiliarize myself and grow as a developer, I wanted to build the same feature with three different state management frameworks.
I've got different experience levels with each of the state management solutions we're trying today.
- Zustand: My go to. I've used it exclusively (when I have a choice) over the last year plus.
- Redux: My prior go to but I haven't used it a lot in a few years. Redux Toolkit is relatively new to me.
- Mobx State Tree: I was only familiar with the name prior to doing the research for this post.
What We're Comparing Between Redux, Zustand, and Mobx State Tree
In our app we'll be looking at
- How we store global data (what setup is involved?)
- How we access dynamic data across different screens in a React Native app
- How we read only the data we need on a screen
- How we mutate data
What We're Building
We'll build a simple e-commerce style app (you can learn how to build a more complex one in our "Build an E-Commerce App with React Native and Stripe" course).
This app will have a list of products and a cart. You can add a product to your cart via the feed and view/remove items from your cart from the cart screen.
The code for each of the examples is available on Github.
Creating the Store
First step with all of these is to define the store in which we keep the data. We're using TypeScript in this example, though it isn't required.
Zustand
In Zustand all we have to do is call create
to spin up a new store. You pass a function the the create
function and what it returns is the value of the hook.
The return value of a Zustand create
works like any other hook: as a value changes it will cause a re-render.
import create from "zustand"
type Product = { sku: string; name: string; image: string }
type CartState = {
products: Product[]
cart: { [sku: string]: number }
addToCart: (sku: string) => void
removeFromCart: (sku: string) => void
}
// Selectors
// ...
// Initialize our store with initial values and actions to mutate the state
export const useCart = create<CartState>(set => ({
products: [
// ...
],
cart: {},
// Actions
// ...
}))
Redux
Redux works similar to Zustand but things are more verbose. You create a slice of data with a name, give it an initial state, define reducers to mutate that data, and then a slice will provide functions you can call to actually cause the data to update.
You then combine those slices into a store.
import { createSlice, configureStore, PayloadAction } from "@reduxjs/toolkit"
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
// Slices
// Definee the shape of the state and how to mutate it
type ICart = { [sku: string]: number }
const cartInitialState: ICart = {}
const cartSlice = createSlice({
name: "cart",
initialState: cartInitialState,
reducers: {
// ...
},
})
type IProduct = { sku: string; name: string; image: string }
const productsInitialState: IProduct[] = [
// ...
]
const productsSlice = createSlice({
name: "products",
initialState: productsInitialState,
reducers: {},
})
// Actions
// ...
// Selectors
// ...
// Store
export const store = configureStore({
reducer: {
cart: cartSlice.reducer,
products: productsSlice.reducer,
},
})
// ...
You then wrap your app with a Provider
component and pass the exported store
to it as a store
prop. This uses Context to then make it available to any other component within your app that is a child of the Provider
.
const App = () => {
return (
<Provider store={store}>
<NavigationContainer>
<Stack.Navigator>{/* ... */}</Stack.Navigator>
<StatusBar style="auto" />
</NavigationContainer>
</Provider>
)
}
Mobx State Tree
Mobx State Tree (MST) has a big difference in that everything is typed with their own system (not TypeScript). I like the way that it splits up defining the data, defining actions on the data, and defining views.
Not sure how I feel about typing if I'm already using TypeScript.
I've seen a few different ways to access shared data in MST from using a local variable (like I'm doing below) to sharing it view React Context.
import { types, Instance } from "mobx-state-tree"
const Product = types.model({
sku: types.string,
name: types.string,
image: types.string,
})
// Model and type our data with mobx state tree
const CartStore = types
.model("CartStore", {
products: types.array(Product),
cart: types.map(types.number),
})
// Actions to mutate the state
.actions(store => ({
// ...
}))
// Views are like selectors
.views(self => ({
// ...
}))
type CartStoreType = Instance<typeof CartStore>
// Spin up a hook to use our store and provide initial values to it
let _cartStore: CartStoreType
export const useCart = () => {
if (!_cartStore) {
_cartStore = CartStore.create({
products: [
// ...
],
cart: {},
})
}
return _cartStore
}
Comparing Store Creation
Outside of API difference they're all pretty much the same. You create the store with initial values and export something to use that store.
The most unique/different is Mobx State Tree (MST). When you initalize the store you're using a type system uniqe to MST.
Additionally, to access a reused store I created a custom hook to retain a reference to it. I'm not sure if this is the best solution. I could also see using React Context to share that store (like Redux does).
Reading Data
Zustand
Reading simple data from Zustand is easy. We just import the useCart
hook we created and pick the data off of the returned object. This data will be update dynamically as it changes and cause a rerender to our app.
// Products.tsx
import { ScrollView, SafeAreaView } from "react-native"
import { ProductCard } from "../shared/ProductCard"
import { useCart } from "./store"
export const Products = () => {
const { addToCart, removeFromCart, cart, products } = useCart()
return (
<ScrollView>
<SafeAreaView>
{products.map(product => (
<ProductCard
key={product.sku}
{...product}
isInCart={cart[product.sku] !== undefined}
onRemove={removeFromCart}
onAdd={addToCart}
/>
))}
</SafeAreaView>
</ScrollView>
)
}
Alternatively if we just want a certain piece of data we can create a function, called a selector in other libraries, to get a specific piece of data.
An example of this is on our Cart screen - we only want to show the products the user currently has in their cart.
// store.tsx
export const selectProductsInCart = (state: CartState) =>
state.products.filter(product => state.cart[product.sku])
We then pass that selector function to the useCart
hook to only get the data we need.
// Cart.tsx
import { ScrollView } from "react-native"
import { CartRow } from "../shared/CartRow"
import { useCart, selectProductsInCart } from "./store"
export const Cart = () => {
const { removeFromCart } = useCart()
const productsInCart = useCart(selectProductsInCart)
return (
<ScrollView>
{productsInCart.map(product => (
<CartRow
key={product.sku}
sku={product.sku}
image={product.image}
name={product.name}
onRemove={removeFromCart}
/>
))}
</ScrollView>
)
}
Redux
Unlike Zustand, with Redux you don't use a hook to access your data. You have a provider component that makes the Redux store available to any child components. You then use a selector hook (useSelector
from react-redux
) to access that state from context.
Since we're using TypeScript here we've got a slight abstraction layer to ensure useSelector
is typed - that's where useAppSelector
comes from.
Then we just use a selector function to grab the pieces of data we want in the component.
// Products.tsx
import { ScrollView, SafeAreaView } from "react-native"
import { ProductCard } from "../shared/ProductCard"
import {
useAppSelector,
addToCart,
removeFromCart,
useAppDispatch,
} from "./store"
export const Products = () => {
const products = useAppSelector(state => state.products)
const cart = useAppSelector(state => state.cart)
const dispatch = useAppDispatch()
return (
<ScrollView>
<SafeAreaView>
{products.map(product => (
<ProductCard
key={product.sku}
{...product}
isInCart={cart[product.sku] !== undefined}
onRemove={() => dispatch(removeFromCart(product.sku))}
onAdd={() => dispatch(addToCart(product.sku))}
/>
))}
</SafeAreaView>
</ScrollView>
)
}
Similarly we can import a selector function we define in our store.
// Cart.tsx
import { ScrollView } from "react-native"
import { CartRow } from "../shared/CartRow"
import {
useAppSelector,
removeFromCart,
useAppDispatch,
selectProductsInCart,
} from "./store"
export const Cart = () => {
const dispatch = useAppDispatch()
const productsInCart = useAppSelector(selectProductsInCart)
return (
<ScrollView>
{productsInCart.map(product => (
<CartRow
key={product.sku}
sku={product.sku}
image={product.image}
name={product.name}
onRemove={() => dispatch(removeFromCart(product.sku))}
/>
))}
</ScrollView>
)
}
Mobx State Tree
Reading basic data (like on the Products screen) works almost exactly the same as in Zustand. Use the hook and get the data.
With one exception: if you take a look at the code below you can see that we wrapped our component in observer
. This function, from mobx-react-lite
will make sure that as data changes the component that is observing the store will update.
When you have computed data it's different though.
import { ScrollView } from "react-native"
import { observer } from "mobx-react-lite"
import { CartRow } from "../shared/CartRow"
import { useCart } from "./store"
export const Cart = observer(() => {
const { productsInCart, removeFromCart } = useCart()
return (
<ScrollView>
{productsInCart.map(product => (
<CartRow
key={product.sku}
sku={product.sku}
image={product.image}
name={product.name}
onRemove={removeFromCart}
/>
))}
</ScrollView>
)
})
MST makes the computed productsInCart
available immediately when calling the hook.
This is accomplished by creating a view
getter in MST. This allows you to compute data from what is in your store and access it just like you would products
or cart
.
// store.tsx
// ...
// Model and type our data with mobx state tree
const CartStore = types
// ...
// Views are like selectors
.views(self => ({
get productsInCart() {
return self.products.filter(product => self.cart.get(product.sku))
},
}))
// ...
Comparing Reading Data
Reading data between these is all pretty similar, though I do like how MST allows you to define different data views
. It's one less thing I have to worry about when using that data in a component.
Writing Data
Finally let's take a look at the different ways that you write data.
Zustand
In Zustand you write the functions that mutate data right next to where you define where and how it's stored. I think this works nicely for smaller data sets.
By calling the provided set
function you change the data and trigger a series of events that will cause the data to change and your UI to update.
// store.tsx
export const useCart = create<CartState>(set => ({
products: [
//...
],
cart: {},
addToCart: (sku: string) =>
set(state => {
return { cart: { ...state.cart, [sku]: 1 } }
}),
removeFromCart: (sku: string) =>
set(state => {
const nextCart = { ...state.cart }
delete nextCart[sku]
return { cart: nextCart }
}),
}))
You then access these functions to write data in the same way you access the ones to read the data.
// Cart.tsx
import { ScrollView } from "react-native"
import { CartRow } from "../shared/CartRow"
import { useCart, selectProductsInCart } from "./store"
export const Cart = () => {
const { removeFromCart } = useCart()
const productsInCart = useCart(selectProductsInCart)
return (
<ScrollView>
{productsInCart.map(product => (
<CartRow
key={product.sku}
sku={product.sku}
image={product.image}
name={product.name}
onRemove={removeFromCart}
/>
))}
</ScrollView>
)
}
Redux
Redux takes a cool approach here, atleast when you're using the Redux Toolkit. When you define your slice with reducers it will create the actions to invoke those reducers automatically.
// store.tsx
// ...
type ICart = { [sku: string]: number }
const cartInitialState: ICart = {}
const cartSlice = createSlice({
name: "cart",
initialState: cartInitialState,
reducers: {
addToCart: (state, action: PayloadAction<string>) => {
return {
...state,
[action.payload]: 1,
}
},
removeFromCart: (state, action: PayloadAction<string>) => {
const nextCart = { ...state }
delete nextCart[action.payload]
return { ...nextCart }
},
},
})
// ...
// Actions
// Export actions to be used in components
export const { addToCart, removeFromCart } = cartSlice.actions
// ...
Then in your component you neeed to import the action you want to call and the useDispatch
hook (or useAppDispatch
if you're using TypeScript) and call the action with the expected inputs (defined in your reducer).
// Cart.tsx
import { ScrollView } from "react-native"
import { CartRow } from "../shared/CartRow"
import {
useAppSelector,
removeFromCart,
useAppDispatch,
selectProductsInCart,
} from "./store"
export const Cart = () => {
const dispatch = useAppDispatch()
const productsInCart = useAppSelector(selectProductsInCart)
return (
<ScrollView>
{productsInCart.map(product => (
<CartRow
key={product.sku}
sku={product.sku}
image={product.image}
name={product.name}
onRemove={() => dispatch(removeFromCart(product.sku))}
/>
))}
</ScrollView>
)
}
Mobx State Tree
Finally with MST you define actions
alongside your model
and views
. This gives you access to the store and you just change the data and it figures out what to do.
// store.tsx
// Model and type our data with mobx state tree
const CartStore = types
.model("CartStore", {
// ...
})
// Actions to mutate the state
.actions(store => ({
addToCart(sku: string) {
store.cart.set(sku, 1)
},
removeFromCart(sku: string) {
store.cart.delete(sku)
},
}))
// Views are like selectors
.views(self => ({
// ...
}))
// ...
Then you just grab the function you want off of the hook in a component in which you're using observe
.
// Cart.tsx
import { ScrollView } from "react-native"
import { observer } from "mobx-react-lite"
import { CartRow } from "../shared/CartRow"
import { useCart } from "./store"
export const Cart = observer(() => {
const { productsInCart, removeFromCart } = useCart()
return (
<ScrollView>
{productsInCart.map(product => (
<CartRow
key={product.sku}
sku={product.sku}
image={product.image}
name={product.name}
onRemove={removeFromCart}
/>
))}
</ScrollView>
)
})
Comparing Writing Data
Reading data is pretty much the same in all of them - only minor differences in the setup/how you make a component listen to data changes.
Conclusion
Each one has its pros and cons. I'm partial to Redux and Zustand as they're feel more familiar to me, though I would absolutely be willing to try MST on a project.
I think Zustand is nice for doing things quickly and managing simple data stores.
Redux is great when things get larger and more complex, at the cost of being a bit more verbose.
MST has some nice features and I really like the way computed values work.
Bonus
A member of the React Native School Community (a perk of joining React Native School) pointed out the library easy-peasy which serves as an abstraction layer on Redux but feels more like Zustand. Best of both worlds?