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.

Screenshot of app

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?

React Native School Logo

React Native School

Want to further level up as a React Native developer? Join React Native School! You'll get access to all of our courses and our private Slack community.

Learn More