Enhancing reducer actions in React Context

State management in ReactJS is uber important, and there is a plethora of content in the wild about how best to do this: from libraries like Redux to using useState in a component. React also has this neat little useReducer hook for using a reducer in your component. This follows the typical reducer pattern of having a state, actions, and the reducer. useReducer is great when you’re working through complex data changes where useState just might not cut it.

useReducer provides a one-component-use state

One big issue with useReducer is that every time you instantiate it, you are creating a new instance of the state, just like useState. This state is unique only to the component you created it in. If you use the same reducer across multiple components, each have their own state. As a result, you can’t access this state across your components without prop drilling or creating a Context.

There certainly is a use case for this, in fact I was using it in this single component manner until the needs changed.

Share your reducer state with Context

I was recently working on a project where we were using useReducer in a component, and that worked well, until requirements changed and we started needing to share this state across multiple components. I started passing it through parameters a few times but instantly got stuck with the need to also manipulate this state. I could’ve passed even more params but why when we have Context.

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

React Documentation

Using Context allows us to instantiate and manage our reducer state once and allow any component under it to gain access to it. It’s a standard approach and well documented everywhere, however it had a limitation. I couldn’t easily access the reducer’s state from an action in that reducer. Redux has an option to use getState() but useReducer didn’t appear to have an option. If there is and I’m missing it, please let me know below in the comments!

To enhance our useReducer approach to include the state, we first start with a reducer and a context:

Basic React Context with useReducer

Before we get to the context, here’s a basic reducer I’ll be using, in which you’ll find the state, actions, and reducer.

Reducer

export const initialState = {
  loading: false,
  error: false,
  errorMessage: '',
  docs: [],
  pinDocOverlayOpen: false,
  slideSearchLoading: {},
  currentPinDocId: '',
  environment: ''
};
export const actions = {
  getPinnedDocuments: () => async (dispatch) => {
    dispatch({ type: API_GET_PINNED_PENDING });
    try {
      ... api calls ...
      dispatch({ type: API_GET_PINNED_SUCCESS, payload: { data } });
    } catch (error) {
      if (error.cancelled) return;
      dispatch({ type: API_GET_PINNED_FAILURE, payload: error.toString() });
      console.error(error);
    }
  },
  pinDocument: (docId, contentType, customParams = '') => async (dispatch) => {
    dispatch({ type: API_POST_PINNED_PENDING });
    try {
      ... api calls ...
      dispatch({ type: API_POST_PINNED_SUCCESS });
    } catch (error) {
      if (error.cancelled) return;
      dispatch({ type: API_POST_PINNED_FAILURE, payload: error.toString() });
      console.error(error);
    }
    actions.getPinnedDocuments()(dispatch);
  }
};
export function reducer(state = initialState, action) {
  switch (action.type) {
    case API_GET_PINNED_PENDING:
    case API_POST_PINNED_PENDING:
      return {
        ...state,
        loading: true,
        error: false,
        errorMessage: ''
      };
    case API_GET_PINNED_FAILURE:
    case API_POST_PINNED_FAILURE:
      return {
        ...state,
        loading: false,
        error: true,
        errorMessage: action.payload
      };
    case API_GET_PINNED_SUCCESS:
      return {
        ...state,
        loading: false,
        docs: action.payload.data
      };
    case API_POST_PINNED_SUCCESS:
      return {
        ...state,
        loading: false
      };
    default:
      return state;
  }
}

With that reducer we can now create our context.

ApiContext

import * as React from 'react';
import { actions as pactions, initialState as pinnedItemsInitState, reducer as pinnedItemsReducer } from 'utils/api/pinnedItems';

const apiContext = React.createContext({
  state: {}, dispatch: () => { }, actions: {}
});

const ApiContext = React.memo(({ children }) => {
  const [state, dispatch] = React.useReducer(pinnedItemsReducer, pinnedItemsInitState);

  React.useEffect(() => {
    pactions.getPinnedDocuments()(dispatch);
  }, []);

  return (
    <apiContext.Provider value={{
      state, dispatch, actions: pactions }}
    >
      { children }
    </apiContext.Provider>
  );
});

export const useApiContext = () => React.useContext(apiContext);

export default ApiContext;

Here’s what we have going on:

  • I like to self-contain the entire context, including it’s consumers, in one file, this makes it a little easier to manage.
  • We create the apiContext and set the default state we’ll be passing down to the components.
    • Sorry, I used the word state here, but this is not the state from the reducer, it’s the state of the Context, which will contain the state of the reducer
  • In the ApiContext component, we useReducer on the imported reducer, getting a state and dispatch.
  • Since ApiContext is just another component (well a higher order component, HOC), I went ahead and threw some code in a useEffect so it runs on load. This simply makes the initial API call to get our documents.
  • the ApiContext HOC returns the Provider for the apiContext with the value set to include the items from the reducer and the useReducer: { state, dispatch, actions: pactions }
    • You can always add more data to the context by adding it here, after your actions
  • As I mentioned in my first bullet, I include the consumer code here, so a consuming component can import this function useApiContext and just start using it, example below.

Now that we have the basics, let’s enhance it to get that necessary state in our actions.

Enhancing the reducer actions

In the ApiContext component, I added the following:

  const [enhancedActions, setEnhancedActions] = React.useState({});
  const theState = React.useRef();
  React.useEffect(() => {
    Object.keys(pactions).forEach((key) => { setEnhancedActions((e) => ({ ...e, [key]: (...args) => { pactions[key](...args)(dispatch, theState.current); } })); });
    enhancedActions.getPinnedDocuments();
  }, []);
  React.useEffect(() => {
    theState.current = state;
  }, [state]);
  ...
  return (
    <apiContext.Provider value={{
      state, dispatch, actions: enhancedActions
    }}
  ...

I created a new version of the actions which automatically send in the state and dispatch so the consuming app didn’t have to worry about it. Instead of a consuming app calling pinDocument(docId, contentType, state)(dispatch) the consumer can just call pinDocument(docId, contentType) and the state and dispatch will be automatically provided.

  • I added a new state object enhanceActions and populate that from the existing pactions in the first useEffect
  • As I loop through the actions, I’m adding the action to enhanceActions with the following line:
    setEnhancedActions((e) => ({ ...e, [key]: (...args) => { pactions[key](...args)(dispatch, theState.current); } })
    • Anytime we call our new enhanced function, we’re passing all of those args through to the original function and sending the dispatch and the state.
    • Wondering about ...args? That collects all of the arguments sent to the function, and then lets me pass it along to the subsequent function. Learn more about rest parameters here.
  • theState is a useRef() and is set in the second useEffect. This effect runs anytime the state changes, and as a result the references to the state will always be up to date.
  • Finally, I modified the Provider value so that actions sends enhancedActions.

Consuming our reducer state

Given my ApiContext component is self-contained, consuming the state and actions is really simple:

...
import { useApiContext } from 'utils/context/ApiContext';
const PinnedItems = () => {
  const {
    actions: { getPinnedDocuments, pinDocument },
    state: { docs, currentPinDocId }
  } = useApiContext();
  const pinIt = (doc) => {
    pinDocument(doc, 'news');
  }
...
return (<>Pinned: { docs.forEach(d => <div>{d.title}</div>) }</>)

A simple example above of getting the values from my state within a component.

Real quick, add some logging

Check out this other post on adding some basic logging to the above: Viewing your state with React’s useReducer.

Why bother with Redux

Before we wrap up, I have to ask: why should we bother with Redux at this point? In this app, I wouldn’t. We are managing one state, a limited number of data fields and actions. I have another app I’ve been working on for a couple of years with my team that has nearly 20 different “stores”, each with their own suite of data parameters and actions. We opted for Redux in that and I wouldn’t leave it. It does afford some real simplicities when dealing with a large amount of states and data. It wouldn’t be impossible to manage dozens of stores/states with this approach, it would just get a lot more complex.

One limitation we should be aware of with using useReducer does require the code accessing the reducer state to be a functional component, unlike Redux. If you had a utility file, i.e. for analytics, you couldn’t simply grab the state, you’d have to pass the state into the utility from a component. useReducer, useRef, useState, etc. are all hooks and require a functional component to work. I haven’t had the need, yet, to go beyond this, but if I do, I’ll see what we can do to still make all of this work out.

What do you think? Are there other strengths to using Redux over React’s useReducer approach?


Interested in receiving content like this right in your inbox, subscribe below!

One thought on “Enhancing reducer actions in React Context

Add yours

Leave a Reply

Up ↑

%d bloggers like this: