State Management in React with Reducer but not Redux

David Cai
7 min readDec 31, 2019

--

I truly believe that if React’s Context and Hook APIs came out before Redux’s inception, the adoption rate of Redux will be much lower compared to what it is now. Redux is likely overused in many projects where state management can be simply done using React’s built-in Context and Hook APIs instead. You might not need Redux after all.

Before the introduction of Context, in a deeply nested component tree, to propagate changes from the top of a component tree down to a leaf node, developers have to pass state through multiple levels of components, e.g. grandparent to parent, to child, or even grand child. Developers will have to introduce a bunch of props just to pass the state down. This problem is commonly known as “prop drilling”.

And when a descendent component needs to communicate changes back to its ancestral component, changes will have to be bubbled up via callback props along the component tree branch from bottom to top. This is the inverted “prop drilling”.

Redux can be used to solve these issues by having a global state store accessible at any level of a component tree.

How do we create such state store without Redux?

(This post is inspired by Kent C. Dodds’ article.)

App

We’d like to declare a state store close to the top level of our component tree. Something like this:

// App.jsimport { AppStore } from "./store";const defaultState = { name: "Jane" };export function App() {
return (
<AppStore defaultState={defaultState}>
<Greeter />
</AppStore>
);
}

AppStore’s child — Greeter and its descendants will now have access to AppStore’s state and its dispatch function. We will go through AppStore in the following sections.

Access store state and dispatch

In our simple example, the Store’s child component — Greeter reads the “name” from the store state, and calls the “dispatch” function to change the name:

// Greeter.jsimport { useAppState, useAppDispatch } from "./store";export function Greeter() {
const { name } = useAppState();
const dispatch = useAppDispatch();
const handleClick = () => {
dispatch({ type: "UPDATE_NAME", name: "Joe" });
};
return (
<button type="button" onClick={handleClick}>
Hello, {name}!
</button>
);
}

The “useAppState” and “useAppDispatch” are two custom React hooks. The former is to read state from the store, the latter is to change state in the store. We will talk about them in a minute. But first, we will dive to the utility that creates Store components.

Store utility

The “createStore” function is a factory that produces a state store.

// util.jsexport function createStore(reducer) {
const StateContext = React.createContext();
const DispatchContext = React.createContext();
function Store({ defaultState = {}, children }) {
const [state, dispatch] = React.useReducer(
reducer,
defaultState
);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>

{children}
</DispatchContext.Provider>
</StateContext.Provider>

);
}
return Store;
}

This function creates two React contexts — one for the store state(StateContext), and one for the dispatch function (DispatchContext). The function returns a Store component which wraps these two contexts and supplies values from these contexts.

The createStore function takes a reducer as its argument. The “reducer” is just your plain old reducer function.

Reducer

// reducer.jsexport function reducer(state, action) {
switch (action.type) {
case "UPDATE_NAME":
return { ...state, name: action.name };
default:
throw new Error(`Unsupported action: ${action.type}`);
}
}

Nothing special here. This is just a standard reducer function.

useStoreState and useStoreDispatch

Now we have a “createStore” function that takes a reducer and returns a Store component, but how can descendant components access the state and dispatch from the store? In order to do that, we need to enhance this “createStore” utility to return two additional custom hooks — “useStoreState” and “useStoreDispatch”:

// util.jsexport function createStore(reducer) {
const StateContext = React.createContext();
const DispatchContext = React.createContext();
function Store({ defaultState = {}, children }) {
const [state, dispatch] = React.useReducer(
reducer,
defaultState
);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
function useStoreState() {
const context = React.useContext(StateContext);
if (!context) {
throw new Error(
"useStoreState must be used within a Provider"
);
}
return context;
}
function useStoreDispatch() {
const context = React.useContext(DispatchContext);
if (!context) {
throw new Error(
"useStoreDispatch must be used within a Provider"
);
}
return context;
}
return [Store, useStoreState, useStoreDispatch];
}

These two custom hooks simply call React.useContext to read the values from the contexts. The “useStoreState” hook returns the store state, and the “useStoreDispatch” hook returns the dispatch function. They also added checks to ensure they can only be used inside their individual context providers.

Notice that we included these two custom hooks in the returned array together with the Store component.

Now let’s see how we use this function to create a state store.

State store

The “createStore” function shown above is generic and reusable. In our example, we use it to create an AppStore component, and two custom hooks — useAppState and useAppDispatch:

// store.jsimport { createStore } from './util';
import { reducer } from "./reducer";
const [
AppStore,
useAppState,
useAppDispatch
]
= createStore(reducer);
export { AppStore, useAppState, useAppDispatch };

Now we have a store, and two hooks to access it.

Unlike Redux, you are encouraged to create multiple stores, for example, we can use createStore to create a SessionStore for managing user sessions, or a WorkingCopyStore for caching user’s input in a rich text editor, etc. And these stores don’t have to be always at the top level of a component tree. They are usually used to wrap complicate components, and provide state management for those components.

Debuggability

Is it even a word?

One benefit of using Redux is the awesome developer tool that tracks state changes, and presents them in a nice way right in your browser. This made debugging Redux apps very easy. With our createStore function, we might be able to provide similar debuggability by adding just a little bit code to the createStore function:

Track actions

To report actions in their dispatched sequence, we can “proxy” the dispatch function and invoke a callback to notify the interested party:

// util.jsexport function createStore(reducer) {
...
function Store({
defaultState = {},
onDispatch,
children
}) {
const [state, baseDispatch] = React.useReducer(
reducer,
defaultState
);
const dispatch = React.useCallback((action) => {
baseDispatch(action);
if (onDispatch) {
onDispatch(action);
}
}, [onDispatch]);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
...
}

Then, in the app, we simply console log actions:

// App.js...export function App() {
const handleDispatch = React.useCallback((action) => {
console.log(action);
}, []);
return (
<AppStore
defaultState={defaultState}
onDispatch={handleDispatch}
>
<Greeter />
</AppStore>
);
}

Feel free to replace our best friend “console.log” with some fancy pants such as Splunk log. JK. Love Splunk. Anyway, this is an example of the output in Chrome:

Track actions in their dispatched order

Inspect store state

Store state is maintained inside React context’s provider, however, context provider is not very obvious to be found in React Developer Tools, especially in deeply nested component trees. React Developer Tools will just display a bunch of generic Context.Providers in the “component” panel like this:

Too many Providers! Which is which?

What we want is to easily find context provider from our state store, and inspect its “value” prop, since the value prop is actually the store state.

Well, it is very easy solve this problem. The magic word is “displayName”. Pass a name to the createStore function, and assign the name to the context’s displayName:

// util.jsexport function createStore(name, reducer) {
const StateContext = React.createContext();
const DispatchContext = React.createContext();
StateContext.displayName = `${name}State`;
DispatchContext.displayName = `${name}Dispatch`;
...
}

For example, we name it “AppStore” when creating our state store:

// store.js...const [
AppStore,
useAppState,
useAppDispatch
] = createStore("AppStore", reducer);
export { AppStore, useAppState, useAppDispatch };

Now, searching by the name “AppStore” in React Developer Tools will target our store:

Search by “AppStoreState” and inspect its value prop

Select the “AppStoreState.Provider”, and expand the “value” prop. Store’s state will be listed here. The value prop will be updated every time the store state changes. This gives us a real-time inspection of the store state. Very handy.

Conclusion

With React Context and hook APIs, we can leverage reducers to manage state without Redux. A single createStore function wraps 40 lines of boilerplate code. Developers only need to define the store, and use the custom React hooks to access the store. Any React user should be able to pick up this pattern quickly.

However, I am not saying this patten should be used to replace Redux 100% of the time. Some features provided by Redux such as time travel don’t exist in this alternative solution. But do you really need these features for your projects? You should ask yourself this question. For most projects, React’s built-in APIs are already capable enough to handle state management.

Here is the full source code:

--

--