DEV Community

Cover image for Mastering State Management in React: A Guide to useContext and useReducer Hooks
Emmanuel Fordjour  Kumah
Emmanuel Fordjour Kumah

Posted on

Mastering State Management in React: A Guide to useContext and useReducer Hooks

In this article, you'll learn how to manage the state in a React app using the Context API and the reducer function. The context API enables access to the state globally without prop drilling. Reducers provides a structured approach to update state logic.

At the end of the tutorial, you will build a contact address book that uses the context API, reducer functions, and React hooks. Here is the code

Prerequisite

To begin, you should be familiar with:

  • Building a React app

  • Using useState hooks in the React app

Let’s get started!

Overview of state management?

When building complex web applications, you should learn how to manage and synchronize the application's data effectively. This means:

  • Keeping state organized

  • Structuring how the state is updated

  • And how the data flows within the components of your application.

You can use state management libraries like Redux, Mobx, and Zustand to manage. This tutorial focuses on managing the state using the contextAPI and the reducer function.

Let’s understand these concepts.

The Context API: Passing data from parent to child components without prop drilling

Generally, we use props to pass data from parent to child components. The challenge is that, if the element requesting data is located further away from the parent, we will pass that data via props through many components in the middle. With this approach, components that do not need access to the data will still have to receive it, and then pass it on to the required component. This is generally referred to as prop drilling. Passing data via props can be cumbersome for certain types of props (e.g User authentication, theme, locale preference) that are required by many components within an application

To avoid prop drilling, you can use the Context API. Context enables a parent component to share values between components without explicitly passing props. This approach, allows the parent to make the data available to any element in the tree below it.

contextapi

Inserting the state logic into a Reducer

The more complex your components become, the more difficult it becomes to manage the state logic. Reducers combine all the state update logic (e.g. Add contact, Edit contact, Delete contact) in a single function. This means, that when an event is fired within a component, instead of the state update occurring in that component, all the updates will happen in a reducer function.

Combining all the state updates into a single function (reducer) provides a window for you to view all the state updates at a glance.

reducer

You have learned what the Context API and reducer do. Next, you will learn how to add the Context API in a React app.

Steps to add context in the React app

Follow these steps, to add the Context API in React:

  1. Create the context: First, you create the context by importing the createContext method from React. This method provides a context that components can provide or read. It returns an object with a SomeContext.Provider and SomeContext.Consumer properties which are also components. The SomeContext.Provider component lets you give some values to components.

    The code below indicates how to create a context

    //Context.js
    import { createContext } from 'react';
    //create a SomeContext object and export 
    export const SomeContext = createContext();
    //properties in the SomeContext object are:
    // SomeContext.Provider , SomeContext.Consumer which are also components
    
  2. Wrap your components into a Context Provider and pass it some values: Every context you create has a Provider component. The provider component is wrapped around the component tree. It accepts the value prop.

    The code below illustrates a provider component with a value prop.

    //ceate a context object called SomeContext
    export const SomeContext = createContext();
    // create a provider component for the SomeContext object and pass it a value
    <SomeContext.Provider value="some context value accessible by all components">
      <App/>
    </SomeContext.Provider>
    

    Defining a component that returns the Provider and using that component to wrap up the main is recommended.

    //MyProvider.jsx
    //Define  a component called MyProvider that return the Context Provider component
    function MyProvider(props) {
        return (
            <SomeContext.Provider value="some context value accessible by all components">
                {props.children}
            </SomeContext.Provider>
        );
    }
    export default MyProvider
    
    // App.js
    //wrap the MyProvider component around the App component
    //This ensures the value provided is accessbile by all components.
    function App(){
      return (
       <MyProvider>
         <App/>
       </MyProvider>
    )
    }
    
  3. Use the context value: To use the context value in any component

    1. Import the useContext hook from React.
    2. Import the created context (e.g. SomeContext)
    3. Call the useContext hook. It accepts the created context as an argument and returns the context value
//MyComponent.jsx
import { useContext } from 'react';
import {SomeContext} from "./Context"
//MyComponent is child component that need access to the context value
const MyComponent = ()=>{
//return the context value
const value = useContext(SomeContext);

return (
<div>The child component has the context value displayed here:{value}</div>
)
}
Enter fullscreen mode Exit fullscreen mode

Congratulations, you know how to use the context API to pass data deeply in the component tree without prop drilling.

Next, let’s learn how the reducer function combines state updates.

Steps to add reducers in your React app

Follow these steps to define the reducer function in your React app:

  • Create a reducer function: The reducer function contains all the logic for the state update. It takes the current state and action as parameters and returns the new state based on the type of action sent by the event handler function in the component

    //reducer.js
    
    //define the reducer function. It accepts the state and action object
    function yourReducer(state, action) {
    // all logic to upate the state will be here  
     if(action.type === "added"){
     //perform this state update logic
    }else if (action.type === "deleted"){
     // perform this state update logic
    }
    ...
    // return next state for React to set
    }
    
  • Use the state logic in your component: Import the useReducer hook from React to use the reducer function in any component. This hook accepts the reducer function, and the initial state as arguments and returns the current state and a dispatch function. The current state and dispatch will be passed to other components using context.

  • Call the dispatch in the event handler function to update the state: Don’t manage the state in your components. Instead, when an event happens (e.g when you submit user details), call the event handler function, which then dispatches an action (send an action) to the reducer function. The reducer function examines the type of action sent. If the action type sent matches any action type in the reducer it updates the state and returns the latest state.

//insider your function component eg. MyComponent.jsx
//import the useReducer hook and call it inside the component
import {useReducer} from "react"
import yourReducer from "./reducer"
const MyApp = ()=> {
const [state, dispatch] = useReducer(yourReducer, initialState)
//state is the current state of the app
//dispatch allows you to send action to update the state

const handleAddContact(){
//dispatch is a function that will trigger the state changes in the reducer
    dispatch({
      type: 'added',
      id: nextId++,
      text: name,
    });
}
return (
<div>
  <h2>Dispatching action </h2>
  <button onClick={handleAddContact}>Click here to dispatch action </button>
</div>
)
}
Enter fullscreen mode Exit fullscreen mode

Here is a summary of the reducer function usage:

  • Instead of defining the state update logic in the required component, the logic is in the reducer function.

  • Call the dispatch function in your component and within an event handler function. The dispatch function contains the occurred action.

  • Based on the action type, the reducer function will update the state and return the latest state.

dispatchaction

Combining the Reducer and Context API

Let’s learn how to combine the reducer and context API in our React app.

  • Use the context API to pass data deeply to child components without prop drilling

  • Use the reducer function to handle the state update logic

  • The state is updated based on the action type

  • The latest state can be accessed with the useReducer hook.

  • The state is passed down to the other component using the Context Provider component

  • The state value is read using the useContext hook.

Let’s combine all this knowledge to build our contact address book app.

Project: Building Contact address app

In this section, we will build a contact address app using the Context API and reducer knowledge. The app will enable users to:

  • Add Contact

  • Edit contact

  • Delete contact

app

Setting up the React app

Set up your React environment using any preferred library and run the app. These are the steps to follow:

  • Create a component folder in your src directory. All you need in the component folder are Form.jsx, ContactList.jsx, ContactDetails.jsx components. The Form.jsx contains the input elements for adding contacts. The ContactList maps over the array of contacts, and display each contact detail in the ContactDetails component.

  • Create a context folder. Define a Provider.jsx component inside that folder. In the Provider component create the context and pass your state to the value prop in the ContactContext.Provider component.

  • Create areducer folder. Create a contactReducer.jsx file inside the folder. Define all the logic to update the state in the reducer function

  • In the App.jsx component, import the Form, ⁣ContactList. To make the context value accessible by all components, wrap the Provider components around the component tree.

Let’s begin with adding context to our app.

Adding Context to our app

First, we will create the ContactContext in the Provider component. Follow these steps:

  • Import createContext API from React

  • Create and export the ContactsContext

//component/context/Provider.jsx
import { createContext } from "react";
export const ContactsContext = createContext(null); // context with a default value of null
Enter fullscreen mode Exit fullscreen mode

The ContactsContext provide access to the ContactsContext.Provider component. In the value prop, you will pass the data to access in the component tree.

//Here the provider component accepts a string as value though it can accept object
<ContactsContext.Provider value="some value">
        {children}
 </ContactsContext.Provider>
Enter fullscreen mode Exit fullscreen mode

Let’s declare the Provider component which accepts children as props and returns the ContactsContext.Provider. Go ahead and add the code below in the Provider.jsx file

// context/Provider.jsx file 
//code above remains the same 
import { createContext} from "react";
import {useState} from "react"

//create the context
export const ContactsContext = createContext(null);

//declare the Provider component which returns the ContactsContext.Provider component
const Provider = ({ children }) => {
//The data to pass to the component tree is in a state variable
  const [state, setState] = useState('some value')
  return (
    <>
      <ContactsContext.Provider value={state}>
        {children}
      </ContactsContext.Provider>
    </>
  );
};

export default Provider;
Enter fullscreen mode Exit fullscreen mode

Wrap the Provider component around your components.

Next, we will wrap the Provider component around the Form and ContactList components. This ensures the values assigned to ContactsContext.Provider is accessed by all child components inside the App.jsx

//App.jsx
import "./styles.css";
import Provider from "./context/Provider";
import Form from "./component/Form";
import ContactList from "./component/ContactList";

export default function App() {
  return (
    <div className="App">
      <Provider>
        <Form />
        <ContactList />
      </Provider>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Access the context value with the useContext hook

Now, we want to access the context value in the ContactList component. But first, let’s modify the Provider.jsx component and pass the initialContact as the state variable

//Provider.jsx
import { createContext } from "react";

export const ContactsContext = createContext(null);
//define an initial state for the app
const initialContact= {
  contacts: [
    {
      id: Math.floor(Math.random() * 10),
      name: "Emmanuel Kumah",
      phone: "0244234123",
    },
  ],
  editingContact: null,
};

const Provider = ({ children }) => {
// pass the initial contact as the state variable
  const [state, setState] = useState(initialState)
//pass the state as the context value
  return (
    <>
      <ContactsContext.Provider value={state}>
        {children}
      </ContactsContext.Provider>
    </>
  );
};

export default Provider;
Enter fullscreen mode Exit fullscreen mode

To use the context value in the ContactList component:

  • Import ContactsContext. This is the context you created with createContext API in the Provider.jsx file

  • Import the useContext hook from React.

  • Call the useContext hook and pass the ContactsContext as an argument.

This returns the state. We loop through the array and display details in the SingleContact component.

//components/ContactList.jsx
//import the useContact hook 
import { useContext} from "react";
import SingleContact from "./SingleContact";
//import the created context
import { ContactsContext } from "../context/Provider";

const ContactList = () => {
//call the useContext hook and pass the ContactsContext
  const state = useContext(ContactsContext);


  return (
    <div>
      <section className="contacts">
        <h3>Your Address book </h3>
        {state.length === 0 ? (
          <p>Start Add contacts</p>
        ) : (
          <ul>
            {state.map((contact) => (
              <div key={contact.id}>
                <SingleContact key={contact.id} contact={contact} />
              </div>
            ))}
          </ul>
        )}
      </section>
    </div>
  );
};

export default ContactList;
Enter fullscreen mode Exit fullscreen mode

The app should look something like this:

app2

Now, it doesn't matter how many layers of components you have in your app. When any component calls the useContext(ContactsContext), it will receive the context value ( the state)

Add the reducer logic

In the previous section, you learned how to add and access the context value in the ContactList component. In this section, we will learn how to manage state logic using the reducer and pass the updated state to the components using the context.

Let’s create a contactReducer.jsx file in our reducer folder. We will define a reducer function in this file. Previously, we learned that a reducer function accepts the current state and action as arguments and returns the updated state based on the action type.

Let’s define the reducer function

export const contactReducer = (state, action )=>{
//return the next state for React to set
switch(action.type){
}
}
Enter fullscreen mode Exit fullscreen mode

We will dispatch three action types to the reducer function. These are

  • Add_Contact

  • Delete_Contact

  • Edit_Contact

Because we want to handle all the state logic in a single function, we will use a switch statement to handle each action object. Dispatched actions have a type property to indicate the type of action dispatched.

Let’s see how to handle each action.type

  • If an action.type matches the Add_Contact , we will define the logic to add a new contact to the state and return the updated state

  • If an action.type matches the Delete_Contact, we will define the logic to remove that contact from the state and return the updated state

  • If an action.type matches the Edit_Contact, we will edit the contact details and return the updated state

  • If the dispatched action.type does not match any of the cases, we will return the state.

reducerFunction

Here is the complete code for the contactReducer function and

//contactReducer.jsx
export const contactReducer = (state, action) => {
  switch (action.type) {
    case "Add_Contact": {
// handle the logic for adding a contact
      return {
        ...state,
        contacts: [...state.contacts, { id: Date.now(), ...action.payload }],
        editingContact: null,
      };
    }
    case "Delete_Contact": {
//handle the logic for deleting a contact
      return {
        ...state,
        contacts: state.contacts.filter(
          (contact) => contact.id !== action.payload
        ),
        editingContact: null,
      };
    }
    case "Edit_Contact": {
//handle the logic for editing a contact
      return {
        ...state,
        contacts: state.contacts.map(
          (contact) => {
            if (contact.id === action.payload.id) {

              return action.payload;
            } else {
              return contact;
            }
          }

        ),
        editingContact: null,
      };
    }
    case "SetEdit_Contact": {
      return {
        ...state,
        editingContact: action.payload,
      };
    }

    default: {
      throw Error("Unknown action", action.type);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Use the reducer from your component

We defined the logic to update the state, now we want to manage this state in our app. We will use the useReducer hook. It is similar to useState, but it is designed for managing a more complex state.

The useReducer hook accepts two arguments:

  • A reducer function

  • An initial state

And it returns:

  • A stateful value ( a state)

  • A dispatch function. To assist in dispatching the actions to the reducer.

We will use the state in the Context.Provider component. This is to allow all the components access to the state.

Let’s see how to accomplish our task.

  • In the Provider component, import the useReducer hook and contactReducer function.

  • Call the useReducer hook and pass the contactReducer and initialState

  • This will return the stateful value and the dispatch function

Here is how to achieve that:

//Provider.jsx
import { createContext, useReducer } from "react";
import { contactReducer } from "../reducer/contactReducer";

//define the initial state
const initialState = {
  contacts: [
    {
      id: Math.floor(Math.random() * 10),
      name: "Emmanuel Kumah",
      phone: "0244234123",
    },
  ],
  ed
const Provider = ({ children }) => {
  //use the useReducer hook
  const [state, dispatch] = useReducer(contactReducer, initialState);
//state contains the initial state
//dispatch enables you to update state
    return (
        ...
)

};
Enter fullscreen mode Exit fullscreen mode

Pass the state and dispatch function as context value

Lastly, we will pass the state and dispatch as an object to the value prop of the Contacts.Provider component. This will enable all the children component access these values

Here is the code below

//Provider.jsx
const Provider = ({ children }) => {
  //manage state with reducers
  const [state, dispatch] = useReducer(contactReducer, initialState);
//pass state and dispatch to the provider component
  return (
    <>
      <ContactsContext.Provider value={{ state, dispatch }}>
        {children}
      </ContactsContext.Provider>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here is the complete code:

import { createContext, useReducer } from "react";
import { contactReducer } from "../reducer/contactReducer";

export const ContactsContext = createContext(null);

const initialState = {
  contacts: [
    {
      id: Math.floor(Math.random() * 10),
      name: "Emmanuel Kumah",
      phone: "0244234123",
    },
  ],
  editingContact: null,
};

const Provider = ({ children }) => {
  //manage state with reducers
  const [state, dispatch] = useReducer(contactReducer, initialState);
  return (
    <>
      <ContactsContext.Provider value={{ state, dispatch }}>
        {children}
      </ContactsContext.Provider>
    </>
  );
};

export default Provider;
Enter fullscreen mode Exit fullscreen mode

Dispatching actions in your components

Finally, let’s dispatch actions to the reducer to update the state.

We have a reducer function (contactReducer) which contains all the state update logic. This means our component would be free of state update logic. It will mainly contain event handlers which dispatch an action object anytime an action occurs.

The Form.jsx component holds the form elements. In this component, we will listen for a change in the input fields and update the name and phone state. On submission, we will dispatch the Add_Contact action to the reducer function. The reducer will check if the action type( Add_Contact) matches any case. If it does, it updates the state by adding the new contact.

In the Form.jsx, we access the dispatch function using the useContext hook. Thedispatch accepts an object with type and payload as properties.

  • The type key enables you to specify the type of action that occurred

  • The payload holds any data to pass to the reducer.

Here is the code for dispatching an action on form submission


  const onFormSubmit = (e) => {
    e.preventDefault();
    if (state.editingContact) {

      dispatch({
        type: "Edit_Contact",
        payload: { id: state.editingContact.id, name, phone },
      });
    } else {
      dispatch({
        type: "Add_Contact",
        payload: {
          id: Date.now(),
          name,
          phone,
        },
      });
    }

    //clear input fields
    setName("");
    setPhone("");
  };
Enter fullscreen mode Exit fullscreen mode
  • In the code above, If the state.editingContact is true, it indicates we want to edit a contact. Hence we dispatch a Edit_Contact action type, and pass the edited contact details to the payload property.

  • Else we dispatch a Add_Contact action type, and pass the details of the contact to the payload property.

Here is the complete code for the Form.jsx component

//Form.jsx
import { useState, useEffect, useContext } from "react";
import { ContactsContext } from "../context/Provider";

const Form = () => {
  const [name, setName] = useState("");
  const [phone, setPhone] = useState("");

  const { state, dispatch } = useContext(ContactsContext);


  useEffect(() => {
    if (state.editingContact) {
      setName(state.editingContact.name);
      setPhone(state.editingContact.phone);
    }
  }, [state.editingContact]);

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setContactDetails((prevState) => ({ ...prevState, [name]: value }));
  };

  const onFormSubmit = (e) => {
    e.preventDefault();
    if (state.editingContact) {
   // dispatch Edit_Contact if you are editing
      dispatch({
        type: "Edit_Contact",
        payload: { id: state.editingContact.id, name, phone },
      });
    } else {
// dispatch Add_Contact if we want to add a new contact
      dispatch({
        type: "Add_Contact",
        payload: {
          id: Date.now(),
          name,
          phone,
        },
      });
    }

    //clear input fields on form submission
    setName("");
    setPhone("");
  };
  return (
    <>
      <section className="formSection">
        <h2 className="heading">Create your buddy list</h2>

        <form onSubmit={onFormSubmit}>
          <input
            type="text"
            name="fullname"
            placeholder="Enter  name"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />

          <input
            type="text"
            name="phone"
            id=""
            placeholder="mobile"
            value={phone}
            onChange={(e) => setPhone(e.target.value)}
          />
          <button className="btnAdd" type="submit" disabled={name === ""}>
            {`${state.editingContact ? "Update" : "Save"}`}
          </button>
        </form>
      </section>
    </>
  );
};

export default Form;
Enter fullscreen mode Exit fullscreen mode

Finally, in the ContactDetails component, we will dispatch the Delete_Contact type to delete a contact, and dispatch the Set_EditContact if we are editing a contact.

Take a look at the code below:

import { useContext } from "react";
import { ContactsContext } from "../context/Provider";

const ContactDetails = ({ contact }) => {
  const { dispatch } = useContext(ContactsContext);

  const handleEdit = () => {
    dispatch({
      type: "SetEdit_Contact",
      payload: contact,
    });
  };
  const handleDelete = () => {
    dispatch({
      type: "Delete_Contact",
      payload: contact.id,
    });
  };
  return (
    <>
      <div className="contact">
        <h3 className="contactName">Name: {contact.name}</h3>
        <p>Contact:{contact.phone}</p>
        <div className="actionBtn">
          <button className="btnContact" onClick={handleEdit}>
            Edit
          </button>
          <button className="btnContact" onClick={handleDelete}>
            Delete Contact
          </button>
        </div>
      </div>
    </>
  );
};

export default ContactDetails;
Enter fullscreen mode Exit fullscreen mode

Conclusion

Congratulations, you have learned how to use context API and the reducer hook. You have learned that:

  1. Context enables a parent component to share values between components without explicitly passing props

  2. Reducers combine all the state update logic (e.g. Add contact, Edit contact, Delete contact) in a single function. This means, that when an event is fired within a component, instead of the state update occurring in that component, all the updates will happen in a reducer function.

  3. useContext is a React Hook that lets you read and subscribe to context from your component

  4. useReducer is a React Hook that lets you add a reducer to your component

Top comments (0)