Edmund Ekott Frontend engineer who specializes in building complex UIs with JavaScript and CSS.

Comparing React Native state management libraries

7 min read 1976

If you’ve had to work on an application where more than two components with different ancestry had to share the same state, you understand that passing props to all these components can get messy fast. State management is a way to manage this data across our app, from the value of a text field to the rows on a table.

Enter state management libraries, like Redux. These libraries aimed to solve this problem, but they still weren’t perfect. The truth is, the perfect state management library does not exist. There are too many different factors to consider when choosing one, like the size of your app, what you want to achieve, and how much state is shared.

In this article, we’ll be looking at some state management options to help you make a decision on which to use in your React Native apps. I will compare the developer experience of state management with the React Context API, Hookstate, and Easy-Peasy.

There are so many articles already written about popular state managers like Redux, so I will be discussing these smaller ones to help you make an informed decision.

Prerequisites

In order to follow along with this article, you should have the following:

I will be using Yarn for this article, but if you prefer npm, be sure to replace the commands with the npm equivalents.

Setting up a demo app

Due to the nature of this article, we won’t be building a new app from scratch. Because I’ll only be discussing how these libraries compare, I set up a demo app where you can follow along as I demonstrate their strengths and weaknesses.

Clone the repo

You can find my demo app at this Github repo. If you clone it locally and install the necessary dependencies, you’ll see that branches have been created for examples of each library we’ll be discussing:

git clone https://github.com/edmund1645-demos/comparing-rn-state-lib

Install dependencies

After cloning the repo to your local machine, install the dependencies using whichever package manager you prefer:

npm install 
#or
yarn install

Run the app

You can take a look around the main branch, especially the App.js file, to get an understanding of how the app is structured before we implement state management.

We made a custom demo for .
No really. Click here to check it out.

Run the app using this command:

yarn ios #or npm 
#or
yarn android

Managing the state with the React Context API

We’ll be looking at the Context API first. Now, I know what you’re thinking: the Context API is not a “standalone” library. While this is true, it’s still an option worth considering.

Check out the context-example branch after cloning the repo and installing the dependencies:

git checkout context-example

Now take a look at the contexts/CartContext.js file:

import React, { createContext } from 'react';
export const initialState = {
  size: 0,
  products: {},
};
export const CartContext = createContext(initialState);

We use the createContext method from React to create a context object and export it. We also pass in a default value.

In App.js, we first import the CartContext object and the default value initialState.

After importing, we need to set up a useReducer hook to modify the state based on the type of action:

//  import context
import { CartContext, initialState } from './contexts/CartContext.js';

// reducer function
const reducer = (state, action) => {
  switch (action.type) {
    case 'ADD_TO_CART':
      if (!state.products[`item-${action.payload.id}`]) {
        return { size: (state.size += 1), products: { ...state.products, [`item-${action.payload.id}`]: { ...action.payload, quantity: 1 } } };
      } else {
        let productsCopy = { ...state.products };
        productsCopy[`item-${action.payload.id}`].quantity += 1;
        return { size: (state.size += 1), products: productsCopy };
      }
  }
};

export default function App() {
  // set up reducer hook
  const [state, dispatch] = useReducer(reducer, initialState);

  // create a Provider and use the return values from the reducer hook as the Provider's value
  return (
    <CartContext.Provider value={[state, dispatch]}>
      {/* other components */}
    </CartContext.Provider>
  )
}

When setting up a context with values that need to be modified, like in our example, we need to use a reducer hook. You’ll notice we are using the values from this reducer hook in the Provider in the code above. This is because the reducer function updates the state, so we want to make the state (and the function to modify it) available across all of our components.

A bit later, you’ll see why children components have access to the value of the Provider and not the default value with which the context was created.

Next, take a look at the components/ProductCard.jsx file:

import React, {useContext} from 'react'
import {CartContext} from '../contexts/CartContext'

const ProductCard = ({ product }) => {
  const [state, dispatch] = useContext(CartContext)

  function addToCart() {
    dispatch({type: 'ADD_TO_CART', payload: product})
  }
  return (
      {/* children */}
   )
}

In order to access the values with which the cart context was created, we need to import it and pass it to the useContext hook.

Notice how the returned value is the array we passed to the Provider earlier, and not the default value the context was created with. This is because it uses the value on the matching Provider up the tree; if there was no CartContext.Provider up the tree, the returned value would be initialState.

When the cart button is clicked, addToCart is invoked, and an action is dispatched to our reducer function to update the state. If you look at the reducer function again, you’ll notice an object is being returned; this object is the new state.

Every time we dispatch an action, a new state is returned just to update a single property on that large object.

Let’s look at the cart screen (screens/Cart.jsx):

import React, {useContext} from 'react'
import { CartContext } from '../contexts/CartContext'

const Cart = () => {
  const [state, dispatch] = useContext(CartContext)
  return (
    {/* children */
  )
}

Here we are using the same pattern as ProductCard.jsx, only this time we’re using just the state to render cart items.

Pros of using the Context API and useReducer

  • Ideal for small projects
  • Doesn’t impact bundle size

Cons of using the Context API with useReducer

  • Updating large objects can get messy quickly
  • May be unsuitable for large projects, because you need to stack multiple Providers up on the tree if the need arises

Managing the state with Hookstate

Hookstate comes with a different approach to state management. It’s simple enough for small applications and flexible enough for relatively large applications.

Checkout the hookstate-example branch:

git checkout hookstate-example

With Hookstate, we use the concept of global state in state/Cart.js. The library exports two functions: createState to create a new state by wrapping some properties and methods around the default state and returning it, and useState to use the state returned from createState or another useState.

import { createState, useState } from '@hookstate/core';

const cartState = createState({
  size: 0,
  products: {},
});

export const useGlobalState = () => {
  const cart = useState(cartState);
  return {
    get: () => cart.value,
    addToCart: (product) => {
      if (cart.products[`item-${product.id}`].value) {
        cart.products[`item-${product.id}`].merge({ quantity: cart.products[`item-${product.id}`].quantity.value + 1 });
        cart.size.set(cart.size.value + 1);
      } else {
        cart.products.merge({ [`item-${product.id}`]: { ...product, quantity: 1 } });
        cart.size.set(cart.size.value + 1);
      }
    },
  };
};

With the way Hookstate is structured, we can also export a helper function for interacting with components inside the state.

All we need to do is import useGlobalState, invoke it in a functional component, and destructure any of the methods from the returned objects (depending on what we want to achieve).

Here’s an example of how we use the addToCart method in components/ProductCard.jsx:

import { useGlobalState } from '../state/Cart';
const ProductCard = ({ product }) => {
  // invoke the function to return the object
  const state = useGlobalState()

  function addToCart() {
  // pass the product and let the helper function deal with the rest
    state.addToCart(product)
  }

  return (
    {/* products */}
  )
}

And on the Cart page in /screens/Cart.js:

import { useGlobalState } from '../state/Cart';
const Cart = () => {
  const {products} = useGlobalState().get()
  return (
    {/* render every item from the cart here */}
  )
}

The best part of Hookstate is that every property or method (both nested and at the top level) in the global state is a type of state, and has various methods to directly modify itself. It is reactive enough to update the state in all components across the app.

Pros of using Hookstate

  • Easy APIs to get the job done
  • Performant
  • Plenty of extensions to create more feature-reach applications
  • Fully typed system

Cons of using Hookstate

I know I said there weren’t any “perfect” alternatives, but it seems Hookstate is attempting to disprove my theory. There is, however, a negligible factor to consider: Hookstate isn’t very well known – it has about 3,000 weekly downloads on npm, so there’s a chance the community around it is small.

Managing the state with Easy-Peasy

Easy-Peasy is an abstraction of Redux, built to expose an easy API that greatly improves the developer experience whilst retaining all the benefits Redux has to offer.

I noticed that working with Easy-Peasy is like working with a combination of the two examples above, because you have to wrap the entire application around a provider (don’t worry, you only need to do that once, unless you want modular states).

To import Easy-Peasy, copy the following into App.js:

import { StoreProvider } from 'easy-peasy';
import cartStore from './state/cart';

export default function App() {

  return (
     <>
      <StoreProvider store={cartStore}>
        {/* children */}
      </StoreProvider>
    </>
  );

You can import hooks from the library to pick out specific parts of the global state that you need inside your components.

Let’s take a look at /state/Cart.js:

import { createStore, action } from 'easy-peasy';
export default createStore({
  size: 0,
  products: {},
  addProductToCart: action((state, payload) => {
    if (state.products[`item-${payload.id}`]) {
      state.products[`item-${payload.id}`].quantity += 1;
      state.size += 1;
    } else {
      state.products[`item-${payload.id}`] = { ...payload, quantity: 1 };
      state.size += 1;
    }
  }),
});

We use createStore to spin up a global store. The object passed is called the “model”. When defining the model, we can also include properties like actions. Actions allow us update the state in the store.

In components/ProductCard.jsx, we want to use the addProductToCart action, so we make use of the useStoreActions hook from Easy-Peasy:

import React from 'react';
import { useStoreActions } from 'easy-peasy';

const ProductCard = ({ product }) => {
  const addProductToCart = useStoreActions((actions)=> actions.addProductToCart)

  function addToCart() {
    addProductToCart(product)
  }
  return (
    {/* children */}
  )
}

If we wanted to use the state in a component, we use the useStoreState hook as seen in screens/Cart.jsx:

import React from 'react';
import { useStoreState } from 'easy-peasy';

const Cart = () => {
  const products = useStoreState((state)=> state.products)
  return (
    {/* children */}
  )
}

Pros of using Easy-Peasy

  • Fully reactive
  • Built on Redux so there’s support for Redux Dev tools and more
  • Easy APIs

Cons of using Easy-Peasy

  • Increased bundle size. If this is a big deal for you, Easy-Peasy may not be your ideal library

Conclusion

In this article, we looked at the comparison between the Context API with hooks, Hookstate, and Easy-Peasy.

To summarize, using the Context API with hooks on demo projects would be ideal, but when your applications start growing in size, it becomes hard to maintain. This is where Hookstate and Easy-Peasy shine.

Hookstate and Easy-Peasy both expose easy APIs to manage the state, and are performant in unique ways. Easy-Peasy was built over Redux so you have those added benefits, and Hookstate has a suite of extensions for implementing features in your application, like local storage persistence for the state.

Many alternatives were not mentioned in this article due to length, so here are some honorable mentions:

You can find the repository for this project here, in case you’d like to inspect the code for each example.

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Edmund Ekott Frontend engineer who specializes in building complex UIs with JavaScript and CSS.

Testing accessibility with Storybook

One big challenge when building a component library is prioritizing accessibility. Accessibility is usually seen as one of those “nice-to-have” features, and unfortunately, we’re...
Laura Carballo
4 min read

Leave a Reply