Bye Redux

Mimic Redux using Context API and useReducer Hook

Redux is a global state manager. React Redux provides bindings for React apps. In this article, I will show how we can manage global state using reducer functions just like how Redux does.

Disadvantages of useReducer hook

useReducer hook has a few limitations. We cannot use the hook with the same reducer more than once. Because of the limitation, a React app using useReducer hook passes down props from the container components down to leaf level components.

Another drawback of the hook is that we have to dispatch an action to a specific reducer. This causes a tight coupling in our apps between the container component and the reducer.

For both these drawbacks or disadvantages, React Context API provides a straight-forward solution. All we have to do is to combine reducers into a root reducer. And then store the reducer state in the context.

Store component

The Store component is a simple wrapper around useReducer hook. All that it does is store the reducer state in the context.

import React, { useReducer } from 'react';
import StoreContext from './StoreContext';

export default function Store(props) {
    const initialState = props.rootReducer(props.initialValue, { type: '__INIT__' });
    const [state, dispatch] = useReducer(props.rootReducer, initialState);
    return (
        <StoreContext.Provider value={[state, dispatch]}>
            {props.children}
        </StoreContext.Provider>
    )
}

The code for the Store component is self-explanatory. It accepts two props: rootReducer and initialValue. We run the initial value through the root reducer with an action type: __INIT__ and get the initial reducer state. We pass the same state again along with the root reducer to the useReducer hook. As the return from useReducer hook, we get the current reducer state and the dispatch function. We pass this as the context value for the StoreContext.

Create a new context – StoreContext which is available throughout the app.

import { createContext } from 'react';
export default createContext();

Using the StoreContext

We use the StoreContext using the useContext hook. The return value of the hook is an array with context as the first item and dispatch function as the second item.

const [state, dispatch] = useContext(StoreContext);

The return type of useContext function call looks different. This is because we deliberately packed the reducer state and the dispatch function into an array.

With this small change, we dispatch all actions to the root reducer. And we don’t have to pass props into nested components. This is because we can call useContext multiple times wherever we want to.

Combining reducers

Writing a utility function to combine reducers to a root reducer is not difficult. But we can use combineReducers function from redux.

import { combineReducers } from 'redux';
import todoReducer from './todoReducer';

export default combineReducers({
    todo: todoReducer
});

After combining all the reducers, we pass the root reducer to the Store component.

import rootReducer from './rootReducer';

export default function App() {
    return (
        <Store rootReducer={rootReducer}>
           <Container />
        </Store>
    );
}

Global state using Context and Hooks

With only a few lines of code, we are able to mimic the functionality of Redux using Context and Hooks. The steps are:

  • Combine reducers using combineReducers of Redux.
  • Pass the root reducer to the Store component.
  • Within the Store component, call the useReducer hook on the root reducer.
  • Store the return values from the useReducer function call in the context using the Context Provider component.
  • Get the reducer state and dispatch function as context using the useContext hook in any component.

The rest of the article is a tutorial on how to convert a Todo app we developed in an earlier article to use the new Store component.

Tutorial

Fork and clone the git repo.

Create a new file StoreContext.js

import { createContext } from 'react';
export default createContext();

Create a new file Store.js for the Store component.

import React, { useReducer } from 'react';
import StoreContext from './StoreContext';

export default function Store(props) {
    const initialState = props.rootReducer(props.initialValue, { type: '__INIT__' });
    const [state, dispatch] = useReducer(props.rootReducer, initialState);
    return (
        <StoreContext.Provider value={[state, dispatch]}>
            {props.children}
        </StoreContext.Provider>
    )
}

Install redux as dependency for making the combineReducers call.

yarn add redux

Create a new file rootReducer.js which has the root reducer. For our example, we have only one reducer – todoReducer. The reducer state is stored in the todo prop.

import { combineReducers } from 'redux';
import todoReducer from './todoReducer';

export default combineReducers({
    todo: todoReducer
});

Create a new Container component. The Container component has the Form and the List component. We do not pass any props from the container component to the child components.

import React from 'react';
import Form from './Form';
import List from './List';

export default function Container() {
    return (
        <div className="app">
            <Form />
            <List />
        </div>
    );
}

Modify the App component to use the Store component and the Container component. We pass the root reducer to the Store component.

import React from 'react';
import './app.css';
import Store from './Store';
import rootReducer from './rootReducer';
import Container from './Container';

export default function App() {
    return (
        <Store rootReducer={rootReducer}>
           <Container />
        </Store>
    );
}

In Form.js, add the following dependencies.

import React, { useState, useContext } from 'react';
import StoreContext from './StoreContext';

Call the useContext hook after the useState call.

const [state, dispatch] = useContext(StoreContext);

Modify the handleAdd function. Instead of calling a prop, we dispatch an action to the root reducer.

function handleAdd(e) {
    e.preventDefault();
    dispatch({
        type: 'ADD_TODO',
        text: value
    });
}

Next, we make changes to the List component. Open List.js and add the following dependencies.

import React, { useState, useEffect, useContext } from 'react';
import StoreContext from './StoreContext';

In the List component, call the useContext hook. We are de-structuring the reducer state to get todo prop which a list of Todo items.

const [{ todo }, dispatch] = useContext(StoreContext);

Remove the useState call for getting the items. And while iterating over the Todo items, use the new todo prop.

{todo.map(item => (

Finally, we make the change to handleDelete function where we dispatch an action to the root reducer.

function handleDelete(id) {
    dispatch({
        type: 'DELETE_TODO',
        id
    });
}

There is no change to the ListItem component.

With a few changes, we have a Redux like Store. All our dispatches are going to the root reducer. All our components have access to the root reducer state.

The final code is in a different repo.

Run the app using Parcel. Open the app at localhost:1234.

useStore custom hook

As a final refactoring, we can create a custom useStore hook.

import { useContext } from 'react';
import StoreContext from './StoreContext';

export default function useStore() {
    return useContext(StoreContext);
}

This avoids the repeated import of StoreContext. But now, we have to do import useStore.

import React, { useState } from 'react';
import useStore from './useStore';

Calling the useStore hook is more intuitive.

const [state, dispatch] = useStore();

To mimic Redux without middleware, we have created a Store component and a useStore hook. The useStore hook is a just a convenience wrapper over useContext hook. In a way, it is beneficial. StoreContext is abstracted away from the app.

UPDATE (11/22/2018):  Created a github repo and npm package named react-reducer-store.

Related Posts

Leave a Reply

Your email address will not be published.