Blog.

Use useReducer As useState Replacement To Handle Complicated Local State

Cover Image for Use useReducer As useState Replacement To Handle Complicated Local State
Andrew Zheng
Andrew Zheng

Introduction

Since I wrote my last blog post about using React Context and Hook APIs, I have been using the combination for a few weeks now, they have made my project much simpler. However, as the complexity of the application increases, useState becomes less ideal for managing complicated local state.

Initial Approach

This was the original implementation of OrderInfoContext and we used it to host data for a list of Orders.

import React, { useState, useEffect, useContext, createContext } from 'react';
import FetchService from './util/FetchService';

const OrderInfoContext = createContext();

// This is a helper component that generate the Provider wrapper
function OrderInfoProvider(props) {
  // We will persist API payload in the state so we can assign it to the Context
  const [orders, setOrders] = useState([]);

  // We use useEffect to make API calls.
  useEffect(() => {
    async function fetchData() {
      // This is just a helper to fetch data from endpoints. It can be done using axios or similar libraries
      const orders = await FetchService
        .get('/v1/registry/orders');
      setOrders(orders);
    }
    fetchData();
  });
  //we create a global object that is avaibvale to every child components 
  return <OrderInfoContext.Provider value={[orders, setOrders]} {...props} />;
}

// Helper function to get Context
function useOrderInfo() {
  const context = useContext(OrderInfoContext);
  if (!context) {
    throw new Error('useOrderInfo must be used within a OrderInfoProvider');
  }
  return context;
}

export { OrderInfoProvider, useOrderInfo };

This approach works fine until I needed to perform some actions such as adding additional order information. While I can still use setOrder to update the state, I faced the following challenges:

  1. multiple child components need to fetch or add a new order
  2. order mutation logic are all over child components

With those challenges in mind. I went with `useReducer hook for help

Solution: useReducer as a replacement

I rewrote the useState with useReducer. useReducer allows developers to manage state like Redux. According to the official React Doc, useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values.

// This is the first attempt to use useReducer hook. 
// reducer returns the next state based on the action passed in
const reducer = (state, action) => {
  switch (action.type) {
    case INITIAL:
      return [];
    case FETCH_ORDER:
      return action.payload;
    case ADD_ORDER:
      return [...state, action.payload];
    default:
      throw new Error();
  }
};

// This is a helper component that generate the Provider wrapper
function OrderInfoProvider(props) {
  // We will persist API payload in the state so we can assign it to the Context
  const [orders, dispatch] = useReducer(reducer, []);
  const addOrderInfo = (data) => { addOrders(dispatch, data); };

  async function fetchOrders() {
    const data = await ApiService
      .get('/v1/registry/orders');
    dispatch({
      type: FETCH_ORDER,
      payload: data
    });
  }

  useEffect(() => {
    fetchOrders();
  }, []);

  // we create a global object that is available to every child components
  return <OrderInfoContext.Provider value={[orders, dispatch]} {...props} />;
}

Since the context exposes dispatch as part of the value, any child component can get the dispatch function from the context and update the order data in the context. This solution is slightly better than the original version since we can have different ways to change the local state.

// any child component can dispatch functions
import { useOrderInfo } from './context/OrderInfoContext';
...
const [, dispatch] = useOrderInfo();

dispatch({
  type: 'ADD_ORDER',
  payload: {
    order_id: 'xyz',
    status: 'SENT'
  }
});

However, It is still not ideal to give child components the burden to re-implement all those dispatch functions over and over again.

Make it even better

To make dispatching functions easier and the code cleaner, I extracted actions, action creator and reducer from OrderInfoContext.

// OrderInfoReducer.js
// actions
import ApiService from './util/apiService';

const INITIAL = 'INITIAL';
const FETCH_ORDER = 'FETCH_ORDER';
const ADD_ORDER = 'ADD_ORDER';

// action creators
export const fetchOrder = data => ({ type: FETCH_ORDER, payload: data });
export const fetchInitial = data => ({ type: INITIAL, payload: data });
export const addOrder = data => ({ type: ADD_ORDER, payload: data });

// reducer
export const reducer = (state, action) => {
  switch (action.type) {
    case INITIAL:
      return [];
    case FETCH_ORDER:
      return action.payload;
    case ADD_ORDER:
      return [...state, action.payload];
    default:
      throw new Error();
  }
};

export async function fetchOrders(dispatch) {
  const data = await ApiService
    .get('/web-registry-api/v1/thankYouNote/orders');
  dispatch(fetchOrder(data.data));
}

export async function addOrders(dispatch, data) {
  dispatch(addOrder(data));
}

After the complex logic is extracted, OrderInfoProvider component becomes much cleaner again. We also create closures from the original fetchOrders and addOrders to provide some restrictions and a better experience for users of those helper functions from OrderInfoContext.

// OrderInfoContext.js
import { reducer, fetchOrders, addOrders } from './OrderInfoReducer';
function OrderInfoProvider(props) {
  const [orders, dispatch] = useReducer(reducer, []);
  const fetchOrderInfo = () => { fetchOrders(dispatch); };
  const addOrderInfo = (data) => { addOrders(dispatch, data); };

  useEffect(() => {
    fetchOrderInfo();
  }, []);

  return <OrderInfoContext.Provider value={{ orders, dispatch, fetchOrderInfo, addOrderInfo }} {...props} />;
}

We can simply call the function from the Context to fetch the order data or add a new order.

import { useOrderInfo } from '../../context/OrderInfoContext';

......

const { fetchOrderInfo } = useOrderInfo();
fetchOrderInfo();

Conclusion

useReducer provides more precise control of local state mutation. It is fairly familiar to developers who already are comfortable with Redux. For any new developers who hasn't used React Hook and Redux, it might not be as straightforward. I also started thinking about the pros & cons of using Context + useReducer as opposed to Redux. But that is the discussion for another day 😀.