How to manage state in React apps with useReducer and useContext hooks

AM

Aman MittalNovember 06, 2020

9 min read1684 words

Choosing a state management library to manage and handle a global state in a React app can be tricky and time-consuming. A lot depends on the scope of the React app and there are many options available.

With the adaption of React Hooks API, one such option is to use a combination of useReducer hook and the Context API. In this post, let's take a look at how to manage global state in a React app using both of them.

Prerequisites#

To take full advantage of this tutorial, or run along with the example, please make sure you following installed/access to in your local development environment.

  • Node.js version >= 12.x.x installed
  • have access to one package manager such as npm or yarn
  • create-react-app cli installed or use npx
  • basics of React Hooks

If you are not familiar with React Hooks, I recommend you to go through the in-depth post on React hooks here.

State management in React apps with useReducer#

There are two types of states to deal with in React apps. The first type is the local state that is used only within a React component. The second type is the global state that can be shared among multiple components within a React application.

With the release of Context API as well as Hooks API, implementing a global state is possible without installing any additional state management library. The useReducer hook is a great way to manage complex state objects and state transitions. You may have seen or used useState to manage simple or local state in React apps.

The useReducer hook is different from useState. The main advantage it has over useState is that covers the use case when there is a need of handling complex data structures or a state object that contains multiple values. It updates the state by accepting a reducer function and an initial state. Then, it returns the actual state and a dispatch function. This dispatch function is used to make changes to the state.

Create a new React app & installing dependencies#

To get started, create a new React project by executing the following command in a terminal window:

npx create-react-app react-expense-tracker

cd react-expense-tracker

To focus on the main topic of this tutorial as well as to give the demo app a nice look and feel, let's use pre-defined components from Reactstrap. It provides Bootstrap 4 components that are based on Flexbox and useful to handle the layout of a web app. To get started using Bootstrap in a React app, install the following dependencies:

yarn add bootstrap@4.5.0 reactstrap@8.5.1 uuid@8.2.0

After installing these dependencies, open the React project you created and open the file index.js. Add an import statement to include the Bootstrap CSS file.

// after other imports
import 'bootstrap/dist/css/bootstrap.min.css';

That's it to set up Bootstrap in the current React app.

Defining a global state#

Start by creating a new file called GlobalState.js inside the src/ directory.

Let's use React's context API to create a Context provider that can is going to share the state across multiple components. You can think of this example as mimicking Redux' philosophy. Import the required statements.

import React, { useReducer, createContext } from 'react';
import { v4 as uuid } from 'uuid';

Next, create an empty context for Expense and define an initial state object. This initial state is going to have one expense item present. This also helps to define a schema or data model for all other expense items (but do note that this for demonstration purpose in context to this post).

export const ExpenseContext = createContext();

const initialState = {
  expenses: [
    {
      id: uuid(),
      name: 'Buy Milk',
      amount: 10
    }
  ]
};

Then define a function called reducer. It is going to take two arguments, the current state and action. This reducer's job is to modify or update the state object whenever there is an action taken within the app by the user. One example of an action is a user adding an expense.

For the following example, this reducer function is going to have one action type, which is to add the expense. If there are no changes or modifications, this reducer function is going to return the current state (which is the default case).

const reducer = (state, action) => {
  switch (action.type) {
    case 'ADD_EXPENSE':
      return {
        expenses: [...state.expenses, action.payload]
      };
    default:
      return {
        state
      };
  }
};

Next, define an ExpenseContextProvider that is going to behave like a store (as a store in Redux).

export const ExpenseContextProvider = props => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <ExpenseContext.Provider value={[state, dispatch]}>
      {props.children}
    </ExpenseContext.Provider>
  );
};

The useReducer hook allows us to create a reducer using the reducer function defined previously. The initialState is passed as the second argument.

Wrap the App with the provider#

When the ExpenseContextProvider is wrapped around any component in the React app, that component and its children will be able to access the current state as well as modify the state object.

In this section, that's what we are going to do. Open, App.js file, and modify it as below.

import React from 'react';
import { Container } from 'reactstrap';

import { ExpenseContextProvider } from './GlobalState';

import Header from './components/Header';
import Form from './components/Form';
import List from './components/List';

export default function App() {
  return (
    <ExpenseContextProvider>
      <Container className="text-center">
        <Header />
        <Form />
        <List />
      </Container>
    </ExpenseContextProvider>
  );
}

In the next sections, let us create other components that are children to this App component. Create a components/ directory and then create three new component files:

  • Header.js
  • Form.js
  • List.js

Add the header of the app#

In this section, let us define a presentational component called Header. It's going to be a simple jumbotron component from Bootstrap displaying the title of the app and the logo.

Open Header.js and add the following snippet:

import React from 'react';
import { Jumbotron } from 'reactstrap';
import Logo from '../logo.svg';

export default function Headers() {
  return (
    <Jumbotron fluid>
      <h3 className="display-6">
        Expense Tracker React App
        <img src={Logo} style={{ width: 50, height: 50 }} alt="react-logo" />
      </h3>
    </Jumbotron>
  );
}

Add a form component#

Open Form.js file and import the following statements.

import React, { useState, useContext } from 'react';
import {
  Form as BTForm,
  FormGroup,
  Input,
  Label,
  Col,
  Button
} from 'reactstrap';
import { v4 as uuid } from 'uuid';

import { ExpenseContext } from '../GlobalState';

The uuid module is going to generate a unique id for each expense item in the global state.

Define a Form component that is going to access values from ExpenseContext using useContext hook.

export default function Form() {
  const [state, dispatch] = useContext(ExpenseContext);

  //...
}

Using the useState reducer, define two state variables that are going to be local to this component. These state variables are going to help us define controlled input fields. A controlled input field accepts its current value as a prop as well as a callback to change that value.

Add the following initial state for name and amount using useState. Both of them are going to have an empty string as their initial value.

const [name, setName] = useState('');
const [amount, setAmount] = useState('');

To update their values when a user starts typing, add the following handler methods. Both of these functions are going to retrieve the value from the corresponding field. The console statements are for testing purposes.

const handleName = event => {
  console.log('Name ', event.target.value);
  setName(event.target.value);
};

const handleAmount = event => {
  console.log('Amount ', event.target.value);
  setAmount(event.target.value);
};

Lastly, to submit the form, there is going to be another handler method called handleSubmitForm. This method when triggered is going to dispatch the action to add the expense (ADD_EXPENSE). This is how the reducer function in the global state updates the state.

const handleSubmitForm = event => {
  event.preventDefault();
  if (name !== '' && amount > 0) {
    dispatch({
      type: 'ADD_EXPENSE',
      payload: { id: uuid(), name, amount }
    });

    // clean input fields
    setName('');
    setAmount('');
  } else {
    console.log('Invalid expense name or the amount');
  }
};

Lastly, add the following JSX to display the component.

return (
  <BTForm style={{ margin: 10 }} onSubmit={handleSubmitForm}>
    <FormGroup className="row">
      <Label for="exampleEmail" sm={2}>
        Name of Expense
      </Label>
      <Col sm={4}>
        <Input
          type="text"
          name="name"
          id="expenseName"
          placeholder="Name of expense?"
          value={name}
          onChange={handleName}
        />
      </Col>
    </FormGroup>
    <FormGroup className="row">
      <Label for="exampleEmail" sm={2}>
        Amount
      </Label>
      <Col sm={4}>
        <Input
          type="number"
          name="amount"
          id="expenseAmount"
          placeholder="$ 0"
          value={amount}
          onChange={handleAmount}
        />
      </Col>
    </FormGroup>
    <Button type="submit" color="primary">
      Add
    </Button>
  </BTForm>
);

Display a list of items#

In this section, let's add the List.js component to display a list of items from the current state object provided by the ExpenseContext. Open the file and add the following import statements:

import React, { useContext } from 'react';
import { ListGroup, ListGroupItem } from 'reactstrap';

import { ExpenseContext } from '../GlobalState';

Next, map the state value to display the name of the expense and the amount of the expense as a list item.

export default function List() {
  const [state] = useContext(ExpenseContext);
  return (
    <ListGroup>
      {state.expenses.map(item => {
        return (
          <ListGroupItem key={item.id}>
            {item.name} - $ {item.amount}
          </ListGroupItem>
        );
      })}
    </ListGroup>
  );
}

Running the app#

All the components of the simple Expense Tracker app are complete. Now, let's run the app and see it as an action. On the initial render, the Rect app is going to look like below.

ss1

It is going to display one expense item that is defined as the object in the initial state. Try adding a new item in the list and see if the list updates and form gets cleared or not.

ss2

Conclusion#

Using useReducer in conjunction with React's Context API is a great way to quickly get started with managing your state. However, some caveats come with React's Context API. Re-rendering of multiple components unnecessarily can become a huge problem and its something you should take care.


🐦 Share on Twitter☕ Buy me a Coffee💌 Subscribe Newsletter

Click the banner below and sign up to Bytes, one of the few newsletters I like to read.

©2020 Aman Mittal

BlogNewsletterTwitterBuy me a coffee