DEV Community

Cover image for Flawless React State Management: useReducer and Context API
sanderdebr
sanderdebr

Posted on

Flawless React State Management: useReducer and Context API

✨ Introduction

Adding state to a React application can be tricky, especially when it's starts to grow in size. On which level do you manage the state? Which components do you give local state only? What about state that you need to access everywhere? Redux is a great library for managing state but it can be overkill in a small to medium application, which you'll probably need to make quite often.

In this tutorial we will build a small user management app that will teach you how to manage state in React with, in my opinion, currently the best way possible.

🎯 Goals

  • Setting up a React app
  • Using 100% React Hooks
  • Using Context API
  • Using useReducer hook
  • Loading API data asynchronously into our state
  • Adding theme toggle switch

📺 What we’ll make

Click here to see the app live in action.
Click hero to check out the github repo.

🔨 Setting up the application

Let's start by creating a new React app with create-react-app:
npx create-react-app user-management

I like to use Material UI or Tailwind in my React projects, let's use Material UI this time:
npm install @material-ui/core

And adding the Roboto font in our index.html:
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />

As well as adding the icon set of Material UI:
npm install @material-ui/icons

Then let's remove all the files except index.js and App.js as we will not use those.

Now we'll create a basic layout and dashboard that will hold a list of our users. We will wrap every page in a _layout.js function that provides a theme and template. Inside App.js we'll add the standard react-router functionality:

_layout.js

import { Box, Container, CssBaseline } from "@material-ui/core";
import React, { useState } from "react";

export default function Layout({ children }) {
  return (
    <>
      <CssBaseline />
      <Container maxWidth="md">
        <Box marginTop={2}>{children}</Box>
      </Container>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

App.js

import { Route, BrowserRouter as Router, Switch } from "react-router-dom";

import Layout from "./_layout";
import Users from "./Users";

function App() {
  return (
    <Layout>
      <Router>
        <Switch>
          <Route path="/">
            <Users />
          </Route>
          <Route path="/edit-user">
            <h1>Edit user</h1>
          </Route>
        </Switch>
      </Router>
    </Layout>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Users.js

import {
  Button,
  Divider,
  Grid,
  Paper,
  Typography,
  makeStyles,
} from "@material-ui/core";

import Brightness7Icon from "@material-ui/icons/Brightness7";
import React from "react";
import UserList from "./UserList";

const useStyles = makeStyles((theme) => ({
  paper: {
    padding: theme.spacing(4),
    margin: "auto",
  },
  img: {
    width: "100%",
  },
  divider: {
    marginBottom: theme.spacing(2),
  },
}));

export default function Users() {
  const classes = useStyles();

  return (
    <Paper className={classes.paper}>
      <Grid container justify="space-between" alignItems="start">
        <Grid item>
          <Typography gutterBottom variant="h4">
            Users
          </Typography>
        </Grid>
        <Grid item>
          <Grid container spacing={4} alignItems="center">
            <Grid item>
              <Button variant="contained" color="primary">
                Load users
              </Button>
            </Grid>
            <Grid item>
              <Brightness7Icon />
            </Grid>
          </Grid>
        </Grid>
      </Grid>
      <Divider className={classes.divider} />
      <UserList />
    </Paper>
  );
}

Enter fullscreen mode Exit fullscreen mode

Also I've added a default icon already for our theme switch that we will make later.

Adding list of users

Let's now add cards that will hold our user information.

UserList.js

import { Grid } from "@material-ui/core";
import React from "react";
import User from "./User";

export default function UserList() {
  const users = [1, 2, 3];

  return (
    <Grid container spacing={2}>
      {users.map((user, i) => (
        <Grid item xs={12} sm={6}>
          <User key={i} user={user} />
        </Grid>
      ))}
    </Grid>
  );
}
Enter fullscreen mode Exit fullscreen mode

User.js

import Button from "@material-ui/core/Button";
import Card from "@material-ui/core/Card";
import CardActionArea from "@material-ui/core/CardActionArea";
import CardActions from "@material-ui/core/CardActions";
import CardContent from "@material-ui/core/CardContent";
import CardMedia from "@material-ui/core/CardMedia";
import React from "react";
import Typography from "@material-ui/core/Typography";
import { makeStyles } from "@material-ui/core/styles";

const useStyles = makeStyles({
  media: {
    height: 140,
  },
});

export default function User() {
  const classes = useStyles();

  return (
    <Card className={classes.root}>
      <CardActionArea>
        <CardContent>
          <Typography gutterBottom variant="h5" component="h2">
            Clementine Bauch
          </Typography>
          <Typography variant="body2" color="textSecondary" component="p">
            <strong>ID: </strong> Samantha
          </Typography>
          <Typography variant="body2" color="textSecondary" component="p">
            <strong>Username: </strong> Samantha
          </Typography>
          <Typography variant="body2" color="textSecondary" component="p">
            <strong>Email: </strong> Nathan@yesenia.net
          </Typography>
        </CardContent>
      </CardActionArea>
      <CardActions>
        <Button size="small" variant="contained" color="secondary">
          Delete
        </Button>
        <Button size="small" variant="contained" color="primary">
          Edit
        </Button>
      </CardActions>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

We currently are just using some hard coded user data and an array with 3 items to show our users. In a further section we will load our users from an API and store them in our app state.

Before that, let's first create the theme switch using the Context API.

💡 Adding Context API

Create a new folder called context and in here add a folder called theme. Inside this folder we'll create the following 3 files: context.js, index.js and reducer.js.

I will explain each file step by step.

context.js
We'll be using React's Context API to wrap our app with some values which we would like to provide, in this case the theme settings.

First we'll create a new context"

const { createContext } = require("react");
const ThemeContext = createContext();`
Enter fullscreen mode Exit fullscreen mode

Then we'll set up a wrapper function that provides the theme to our whole app:

<ThemeContext.Provider theme={currentTheme} setTheme={setTheme}>
      {children}
</ThemeContext.Provider>
Enter fullscreen mode Exit fullscreen mode

To make this work with Material UI, we have to pass the theme into their createMuiTheme() function. We'll use React's useState hook to get and set the state. We'll also provide the setTheme function into our context.

We can consume the context values anywhere in our app by using the useContext() hook:
export const useTheme = () => useContext(ThemeContext);

The whole context then looks like this:

import React, { useContext } from "react";

import { createMuiTheme } from "@material-ui/core";

const { createContext } = require("react");

const ThemeContext = createContext();

export const useTheme = () => useContext(ThemeContext);

export const ThemeProvider = ({ children }) => {
  const dark = {
    palette: {
      type: "dark",
    },
  };

  const currentTheme = createMuiTheme(dark);

  return (
    <ThemeContext.Provider value={currentTheme}>
      {children}
    </ThemeContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Next up we'll use index.js for easy importing our context files in other files:

index.js

import { useTheme, ThemeProvider } from "./context";

export { useTheme, ThemeProvider };
Enter fullscreen mode Exit fullscreen mode

And we'll wrap our App inside App.js with the provider:

App.js

...
function App() {
  return (
    <ThemeProvider>
         ...
    </ThemeProvider>
  );
}
...
Enter fullscreen mode Exit fullscreen mode

We'll update the _layout.js file so that we can provide our theme with material UI:

_layout.js

import {
  Box,
  Container,
  CssBaseline,
  ThemeProvider,
  createMuiTheme,
} from "@material-ui/core";

import React from "react";
import { useThemeState } from "./context/theme";

export const light = {
  palette: {
    type: "light",
  },
};

export const dark = {
  palette: {
    type: "dark",
  },
};

export default function Layout({ children }) {
  const { theme } = useThemeState();

  const lightTheme = createMuiTheme(light);
  const darkTheme = createMuiTheme(dark);

  return (
    <ThemeProvider theme={theme === "light" ? lightTheme : darkTheme}>
      <CssBaseline />
      <Container maxWidth="md">
        <Box marginTop={2}>{children}</Box>
      </Container>
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now we can use the theme anywhere in our app by using the useTheme() hook. For example inside Users.js we can add the following to show a sun or moon based on our theme setting:

const theme = useTheme();
{theme.palette.type === "light" ? 
<Brightness7Icon /> : <Brightness4Icon />}
Enter fullscreen mode Exit fullscreen mode

This is super helpful, we added a global state to our app! But what if we wanted to update that state? That's where the useReducer comes into the picture.

Adding useReducer

The React useReducer hook is an alternative to useState. It acceps a function that mutates the state object, and an initial state object.

The useReducer hook returns the state and a dispatch function, which we can use to fire off changes to our state. It is similar as how Redux works, but less complicated. (I still recommended to learn Redux down the road as it has more benefits for more complex applications).

Because not all components need to access the state and dispatch we will split them up into 2 contexts.

Our new context.js file then looks like the following:

context.js

import React, { useContext, useReducer } from "react";

import { themeReducer } from "./reducer";

const { createContext } = require("react");

const initialState = {
  switched: 0,
  theme: "light",
};

const ThemeStateContext = createContext();
const ThemeDispatchContext = createContext();

export const useThemeState = () => useContext(ThemeStateContext);
export const useThemeDispatch = () => useContext(ThemeDispatchContext);

export const ThemeProvider = ({ children }) => {
  const [theme, dispatch] = useReducer(themeReducer, initialState);

  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeDispatchContext.Provider value={dispatch}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Awesome, next up let's create our first reducer called themeReducer in the file reducer.js.

reducer.js

export const themeReducer = (state, { type }) => {
  switch (type) {
    case "TOGGLE_THEME":
      return {
        ...state,
        switched: state.switched + 1,
        theme: state.theme === "light" ? "dark" : "light",
      };
    default:
      throw new Error(`Unhandled action type: ${type}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

The function above updates the state when an action with label "TOGGLE_THEME" comes in. If the action is unknown, it will throw an error.

We'll also update our initial state and themes inside our context.js file:

context.js

import React, { useContext, useReducer } from "react";

import { createMuiTheme } from "@material-ui/core";
import { themeReducer } from "./reducer";

const { createContext } = require("react");

export const light = {
  palette: {
    type: "light",
  },
};

export const dark = {
  palette: {
    type: "dark",
  },
};

export const lightTheme = createMuiTheme(light);
export const darkTheme = createMuiTheme(dark);

const initialState = {
  switched: 0,
  theme: lightTheme,
};

const ThemeStateContext = createContext();
const ThemeDispatchContext = createContext();

export const useThemeState = () => useContext(ThemeStateContext);
export const useThemeDispatch = () => useContext(ThemeDispatchContext);

export const ThemeProvider = ({ children }) => {
  const [theme, dispatch] = useReducer(themeReducer, initialState);

  return (
    <ThemeStateContext.Provider value={theme}>
      <ThemeDispatchContext.Provider value={dispatch}>
        {children}
      </ThemeDispatchContext.Provider>
    </ThemeStateContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now we can use both switched and theme anywhere in our application with: const { theme } = useThemeState(). Very cool!

Creating the theme toggle

Inside users.js we can now use our dispatch functionality:

Users.js

....
const { theme } = useThemeState();
const dispatch = useThemeDispatch();
...
<Grid item onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
              {theme === "light" ? <Brightness7Icon /> : <Brightness4Icon />}
            </Grid>
Enter fullscreen mode Exit fullscreen mode

Our theme toggle is working, awesome!

Loading users from an API

Let's create a new folder inside our context folder and call it users and add the same files as in theme but now also add actions.js to it.

We'll repeat the same code as we did for the theme context, except we will add actions.js this time as we want to perform an API fetch and based on the result update our state. Our reducer only should be concerned with updated the state directly, performing actions we will keep seperate just like Redux does.

actions.js

export const getUsers = async (dispatch) => {
  dispatch({ type: "REQUEST_USERS" });
  try {
    // Fetch server
    const response = await fetch(`https://jsonplaceholder.typicode.com/users`);

    if (!response.ok) {
      throw Error(response.statusText);
    }

    let data = await response.json();

    // Received users from server
    if (data.length) {
      dispatch({ type: "USERS_SUCCESS", payload: data });
      return data;
    }

    // No match found on server
    dispatch({
      type: "USERS_FAIL",
      error: { message: "Could not fetch users" },
    });

    return null;
  } catch (error) {
    dispatch({ type: "USERS_FAIL", error });
  }
};
Enter fullscreen mode Exit fullscreen mode

When the function above gets called it will fetch user data from an API endpoint. "REQUEST_USERS" will set our state to loading: true. If users are returned, we'll update our state with them in our reducer, if not we'll also update our state error object:

reducer.js

export const usersReducer = (state, { type, payload, error }) => {
  switch (type) {
    case "REQUEST_USERS":
      return {
        ...state,
        loading: true,
      };
    case "USERS_SUCCESS":
      return {
        ...state,
        loading: false,
        users: payload,
      };
    case "USERS_FAIL":
      return {
        ...state,
        loading: false,
        error,
      };
    default:
      throw new Error(`Unhandled action type: ${type}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Now it is up to you to wrap the users context around the application. You can do it the same way as we did for the theme.

Let's fetch our users when the user clicks on our 'LOAD_USERS' button by dispatching the right action:

Users.js

...
  const dispatchUsers = useUsersDispatch();
  const _getUsers = () => getUsers(dispatchUsers);
...
<Button onClick={_getUsers} variant="contained" color="primary">
                Load users
              </Button>
...
Enter fullscreen mode Exit fullscreen mode

Now we can fetch users and save them in the state, let's show them in our application:

UserList.js

import { Grid } from "@material-ui/core";
import React from "react";
import User from "./User";
import { useUsersState } from "../context/users";

export default function UserList() {
  const { users, loading, error } = useUsersState();

  if (loading) {
    return "Loading...";
  }

  if (error) {
    return "Error...";
  }

  return (
    <Grid container spacing={2}>
      {users?.map((user, i) => (
        <Grid key={i} item xs={12} sm={6}>
          <User user={user} />
        </Grid>
      ))}
    </Grid>
  );
}
Enter fullscreen mode Exit fullscreen mode

You can of course add some awesome loading spinners or display a better error, but hopefully you see how easy it is to load the app state wherever you need it and update the UI accordingly.

I would like to invite you to add the delete functionality! By adding a dispatch function to the delete button and removing the users inside the reducer based on its id.

Here is the code:

User.js

...
const dispatch = useUsersDispatch();
...
 <Button
          onClick={() => dispatch({ type: "DELETE_USER", payload: user.id })}
          size="small"
          variant="contained"
          color="secondary"
        >
          Delete
        </Button>
Enter fullscreen mode Exit fullscreen mode

reducer.js

case "DELETE_USER":
      return {
        ...state,
        users: state.users.filter((user) => user.id !== payload),
      };
Enter fullscreen mode Exit fullscreen mode

Persisting our state

One last thing we can do to improve our app is maintaining the app state when the user closes the window. This can be done by storing our states inside the users local storage and is called persisting the state.

First we'll add our state to the local storage every time our state changes inside our context.js files:

context.js

export const ThemeProvider = ({ children }) => {
  const [theme, dispatch] = useReducer(themeReducer, initialState);

  // Persist state on each update
  useEffect(() => {
    localStorage.setItem("theme", JSON.stringify(theme));
  }, [theme]);

  return ( ...
Enter fullscreen mode Exit fullscreen mode

Then we'll change our initialstate to grab the state stored in local storage when it is available, otherwise use the initial state we declared already.

Instead of the initial state we'll pass in a initializer function into our reducers:

reducer.js

...
const initialState = {
  loading: false,
  error: null,
  users: [],
};

const initializer = localStorage.getItem("users")
  ? JSON.parse(localStorage.getItem("users"))
  : initialState;
...
const [state, dispatch] = useReducer(usersReducer, initializer);
Enter fullscreen mode Exit fullscreen mode

We'll do this for both contexts.

You should see your apps state in the local storage of your browser, awesome! 🔥

Click here to see the app live in action.
Click hero to check out the github repo.

There are so many possibilities with these technologies, I hope this tutorial will help you in any kind of way!

Top comments (9)

Collapse
 
droidmakk profile image
Afroze Kabeer Khan. M

I've been preaching this approach, but the only caveat I feel in this is context api does unnecessary re-renders.
Which needs to be again carefully memoized as to prevent unnecessary re-renders.

What's your thought on this?

Collapse
 
sanderdebr profile image
sanderdebr • Edited

True, but I've noticed that React is very smart in comparing and updating, it only updates the components that actually are affected by a state difference, even before memoizing them. I'm using this technique in a production app and only the affected components seem to be re-rendering.

However, I would advise to only store your app-wide state with the context api, otherwise use local state - for example for input forms. So the app would be a combination of context stores and local stores then. Otherwise switch to a state management lib like Redux.

How are you managing state currently?

Collapse
 
markerikson profile image
Mark Erikson

it only updates the components that actually are affected by a state difference

Depends on what you mean by "update" here.

Yes, React only modifies the DOM if your component's render output changes.

However, React always re-renders components recursively by default, even if the props and state haven't changed. It's a very common misconception that React automatically skips rendering if the props are the same - that's an optimization you have to apply.

See my post A (Mostly) Complete Guide to React Rendering Behavior for details.

Collapse
 
polaroidkidd profile image
Daniel Einars

Even for friends the context API becomes a performance issue when your form is split over multiple components.

Collapse
 
droidmakk profile image
Afroze Kabeer Khan. M

I'd say you can use html and native JavaScript implementations for input fields since state updates are async the user feedback can be sometimes bad. But in special cases you can use it, I suppose...

Collapse
 
markerikson profile image
Mark Erikson

Yep. I actually just wrote a post a few days ago that covers this:

Why React Context is Not a "State Management" Tool (and Why It Doesn't Replace Redux)

My primary point is clarifying the actual capabilities and purposes for Context, useReducer, and Redux, but as part of that I also discuss some of the behavior differences.

I'd also suggest reading my posts A (Mostly) Complete Guide to React Rendering Behavior and React, Redux, and Context Behavior, which cover more details on the rendering behavior aspects.

Collapse
 
droidmakk profile image
Afroze Kabeer Khan. M

Cool...

Collapse
 
matjones profile image
Mat Jones

Came here to say this. This is the reason that I think Context API is not really suitable for app-wide state management unless that state is going to change very infrequently.

Collapse
 
arthtyagi profile image
Arth

Add react-query in the mix and this becomes an excellent solution.