Use useReducer As useState Replacement To Handle Complicated Local State
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:
- multiple child components need to fetch or add a new order
- 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 đ.