How to Replace Redux with React Hooks and the Context API

How to Replace Redux with React Hooks and the Context API

The most popular way to handle shared application state in React is using a framework such as Redux. Quite recently, the React team introduced several new features which include React Hooks and the Context API. These two features effectively eliminated a lot of challenges that developers of large React projects have been facing. One of the biggest problems was ‘prop drilling’ which was common with nested components. The solution was to use a state management library like Redux. This, unfortunately, came with the expense of writing boilerplate code — but now, it’s possible to replace Redux with React Hooks and the Context API.

In this article, you are going to learn a new way of handling state in your React projects, without writing excessive code or installing a bunch of libraries — as is the case with Redux. React hooks allow you to use local state inside of function components, while the Context API allows you to share state with other components.

Prerequisites

In order to follow along with this tutorial, you will need to have a good foundation in the following topics:

The technique you will learn here is based on patterns that were introduced in Redux. This means you need to have a firm understanding of reducers and actions before proceeding. I am currently using Visual Studio Code, which seems to be the most popular code editor right now (especially for JavaScript developers). If you are on Windows, I would recommend you install Git Bash. Use the Git Bash terminal to perform all commands provided in this tutorial. Cmder is also a good terminal capable of executing most Linux commands on Windows.

You can access the complete project used in this tutorial for this GitHub Repository.

About the New State Management Technique

There are two types of state that we need to deal with in React projects:

  • local state
  • global state

Local states can only be used within the components where they were defined. Global states can be shared across multiple components. Previously, defining a global state required the installation of a state management framework such as Redux or MobX. With React v16.3.0, the Context API was released which allows developers to implement global state without installing additional libraries.

As of React v16.8, Hooks have allowed implementation of a number of React features in a component without writing a class. Hooks brought vast benefits to the way React developers write code. This includes code re-use and easier ways of sharing state between components. For this tutorial, we will be concerned with the following React hooks:

  • useState
  • useReducer

useState is recommended for handling simple values like numbers or strings. However, when it comes to handling complex data structures, you will need useReducer hook. For useState, you only need to have a single setValue() function for overwriting existing state values.

For useReducer, you will be handling a state object that contains multiple values with different data types in a tree-like structure. You will need to declare functions that can change one or more of these state values. For data types such as arrays, you will need to declare multiple immutable functions for handling add, update and delete actions. You’ll see an example of this in a later section of this article.

Once you declare your state using either useState or useReducer, you’ll need to lift it up to become global state using React Context. This is done by creating a Context Object using the createContext function provided by the React library. A context object allows state to be shared among components without using props.

You will also need to declare a Context Provider for your context object. This allows a page or a container component to subscribe to your context object for changes. Any child component of the container will be able to access the context object using the useContext function. Enough chit-chat, let’s see the code in action.

Setting Up the Project

We’ll use create-react-app to jump-start our project quickly.

$ npx create-react-app react-hooks-context-app

Next, let’s install Semantic UI React, a React-based CSS framework. This isn’t a requirement, I just like creating nice user interfaces without writing custom CSS.

yarn add semantic-ui-react fomantic-ui-css

Open index.js and insert the following import:

import 'fomantic-ui-css/semantic.min.css';

That’s all we need to do for our project to start using Semantic UI. In the next section, we’ll look at how we can declare a state using the useState hook and uplifting it to global state.

Counter Example – useState

For this example, we’ll build a simple counter demo consisting of a two button component and a display component. We’ll introduce a count state that will be shared globally among the two components. The components will be a child of CounterView which will act as the container. The button component will have buttons that will either increment or decrement the value of the count state.

Let’s start by defining our count state in a context file called context/counter-context.js. Insert the following code:

import React, { useState, createContext } from "react"; // Create Context Object
export const CounterContext = createContext(); // Create a provider for components to consume and subscribe to changes
export const CounterContextProvider = props => { const [count, setCount] = useState(0); return ( <CounterContext.Provider value={[count, setCount]}> {props.children} </CounterContext.Provider> );
};

We have defined a state called count and set the default value to 0. All components that consume the CounterContext.Provider will have access to the count state and the setCount function. Let’s define the component for displaying the count state in components/counter-display.js:

import React, { useContext } from "react";
import { Statistic } from "semantic-ui-react";
import { CounterContext } from "../context/counter-context"; export default function CounterDisplay() { const [count] = useContext(CounterContext); return ( <Statistic> <Statistic.Value>{count}</Statistic.Value> <Statistic.Label>Counter</Statistic.Label> </Statistic> );
}

Next, let’s define the component that will contain buttons for increasing and decreasing the state component. Create the file components/counter-buttons.js and insert the following code:

import React, { useContext } from "react";
import { Button } from "semantic-ui-react";
import { CounterContext } from "../context/counter-context"; export default function CounterButtons() { const [count, setCount] = useContext(CounterContext); const increment = () => { setCount(count + 1); }; const decrement = () => { setCount(count - 1); }; return ( <div> <Button.Group> <Button color="green" onClick={increment}> Add </Button> <Button color="red" onClick={decrement}> Minus </Button> </Button.Group> </div> );
}

As it is, the useContext function won’t work since we haven’t specified the Provider. Let’s do that now by creating a container in views/counter-view.js. Insert the following code:

import React from "react";
import { Segment } from "semantic-ui-react"; import { CounterContextProvider } from "../context/counter-context";
import CounterDisplay from "../components/counter-display";
import CounterButtons from "../components/counter-buttons"; export default function CounterView() { return ( <CounterContextProvider> <h3>Counter</h3> <Segment textAlign="center"> <CounterDisplay /> <CounterButtons /> </Segment> </CounterContextProvider> );
}

Finally, let’s replace the existing code in App.js with this one:

import React from "react";
import { Container } from "semantic-ui-react"; import CounterView from "./views/counter-view"; export default function App() { return ( <Container> <h1>React Hooks Context Demo</h1> <CounterView /> </Container> );
}

You can now fire up the create-react-app server using the command yarn start. The browser should start which should render a similar view. Click the buttons to ensure that increment and decrement functions are working:

02-counter-demo.

Let’s go the next section where we’ll setup an example that is a bit more advanced using the useReducer hook.

In this example, we’ll build a basic CRUD page for managing contacts. It will be made up of a couple of presentational components and a container. There will also be a context object for managing contacts state. Since our state tree will be a bit more complex than the previous example, we will have to use the useReducer hook.

Create the state context object context/contact-context.js and insert this code:

import React, { useReducer, createContext } from "react"; export const ContactContext = createContext(); const initialState = { contacts: [ { id: "098", name: "Diana Prince", email: "diana@us.army.mil" }, { id: "099", name: "Bruce Wayne", email: "bruce@batmail.com" }, { id: "100", name: "Clark Kent", email: "clark@metropolitan.com" } ], loading: false, error: null
}; const reducer = (state, action) => { switch (action.type) { case "ADD_CONTACT": return { contacts: [...state.contacts, action.payload] }; case "DEL_CONTACT": return { contacts: state.contacts.filter( contact => contact.id !== action.payload ) }; case "START": return { loading: true }; case "COMPLETE": return { loading: false }; default: throw new Error(); }
}; export const ContactContextProvider = props => { const [state, dispatch] = useReducer(reducer, initialState); return ( <ContactContext.Provider value={[state, dispatch]}> {props.children} </ContactContext.Provider> );
};

Create the parent component views/contact-view.js and insert this code:

import React from "react";
import { Segment, Header } from "semantic-ui-react";
import ContactForm from "../components/contact-form";
import ContactTable from "../components/contact-table";
import { ContactContextProvider } from "../context/contact-context"; export default function Contacts() { return ( <ContactContextProvider> <Segment basic> <Header as="h3">Contacts</Header> <ContactForm /> <ContactTable /> </Segment> </ContactContextProvider> );
}

Create the presentation component components/contact-table.js and insert this code:

import React, { useState, useContext } from "react";
import { Segment, Table, Button, Icon } from "semantic-ui-react";
import { ContactContext } from "../context/contact-context"; export default function ContactTable() { // Subscribe to `contacts` state and access dispatch function const [state, dispatch] = useContext(ContactContext); // Declare a local state to be used internally by this component const [selectedId, setSelectedId] = useState(); const delContact = id => { dispatch({ type: "DEL_CONTACT", payload: id }); }; const onRemoveUser = () => { delContact(selectedId); setSelectedId(null); // Clear selection }; const rows = contacts.map(contact => ( <Table.Row key={contact.id} onClick={() => setSelectedId(contact.id)} active={contact.id === selectedId} > <Table.Cell>{contact.id}</Table.Cell> <Table.Cell>{contact.name}</Table.Cell> <Table.Cell>{contact.email}</Table.Cell> </Table.Row> )); return ( <Segment> <Table celled striped selectable> <Table.Header> <Table.Row> <Table.HeaderCell>Id</Table.HeaderCell> <Table.HeaderCell>Name</Table.HeaderCell> <Table.HeaderCell>Email</Table.HeaderCell> </Table.Row> </Table.Header> <Table.Body>{rows}</Table.Body> <Table.Footer fullWidth> <Table.Row> <Table.HeaderCell /> <Table.HeaderCell colSpan="4"> <Button floated="right" icon labelPosition="left" color="red" size="small" disabled={!selectedId} onClick={onRemoveUser} > <Icon name="trash" /> Remove User </Button> </Table.HeaderCell> </Table.Row> </Table.Footer> </Table> </Segment> );
}

Create the presentation component components/contact-form.js and insert this code:

import React, { useState, useContext } from "react";
import { Segment, Form, Input, Button } from "semantic-ui-react";
import _ from "lodash";
import { ContactContext } from "../context/contact-context"; export default function ContactForm() { const name = useFormInput(""); const email = useFormInput(""); // eslint-disable-next-line no-unused-vars const [state, dispatch] = useContext(ContactContext); const onSubmit = () => { dispatch({ type: "ADD_CONTACT", payload: { id: _.uniqueId(10), name: name.value, email: email.value } }); // Reset Form name.onReset(); email.onReset(); }; return ( <Segment basic> <Form onSubmit={onSubmit}> <Form.Group widths="3"> <Form.Field width={6}> <Input placeholder="Enter Name" {...name} required /> </Form.Field> <Form.Field width={6}> <Input placeholder="Enter Email" {...email} type="email" required /> </Form.Field> <Form.Field width={4}> <Button fluid primary> New Contact </Button> </Form.Field> </Form.Group> </Form> </Segment> );
} function useFormInput(initialValue) { const [value, setValue] = useState(initialValue); const handleChange = e => { setValue(e.target.value); }; const handleReset = () => { setValue(""); }; return { value, onChange: handleChange, onReset: handleReset };
}

Insert the following code in App.js accordingly:

import ContactView from "./views/contact-view";
//...
<Container> <h1>React Hooks Context Demo</h1> {/* <CounterView /> */} <ContactView />
</Container>;

After implementing the code, your browser page should refresh. To delete a contact, you need to select a row first then hit the Delete button. To create a new contact, simply fill the form and hit the New Contact button.

03-contacts-example

Go over the code to make sure you understand everything. Read the comments that I’ve included inside the code.

Summary

I hope these examples provide an excellent understanding of how you can manage shared application state without Redux. If you were to rewrite these examples without hooks and the context API, it would have resulted in a lot more code. See how much easier it is to write code without dealing with props?

You may have noticed in the second example that there are a couple of unused state variables, i.e. loading and error. As a challenge, you can progress this app further to make use of them. For example, you can implement a fake delay, and cause the presentation components to display a loading status. You can also take it much further and access a real remote API. This is where the error state variable can be useful in displaying error messages.

The only question you may want to ask yourself now: is Redux necessary for future projects? One disadvantage that I’ve seen with this technique is that you can’t use the Redux DevTool extension to debug your application state. However, this might change in the future with the development of a new tool. Obviously, as a developer, you will still need to learn Redux in order to maintain legacy projects. But if you are starting a new project, you will need to ask yourself and your team if using a third-party state management library is really necessary for your case.

Leave a Reply

Your email address will not be published. Required fields are marked *