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, weuseReducer
on the imported reducer, getting astate
anddispatch
. - Since
ApiContext
is just another component (well a higher order component, HOC), I went ahead and threw some code in auseEffect
so it runs on load. This simply makes the initial API call to get our documents. - the
ApiContext
HOC returns theProvider
for theapiContext
with the value set to include the items from the reducer and theuseReducer
:{ 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 existingpactions
in the firstuseEffect
- 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.
- Anytime we call our new enhanced function, we’re passing all of those args through to the original function and sending the
theState
is auseRef()
and is set in the seconduseEffect
. 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 thatactions
sendsenhancedActions
.
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!