State Management in React: A Practical Example

State Management in React: A Practical Example

#react#state management#context api

React is the go-to framework for building dynamic client-side applications for many developers. The dynamic nature of these applications comes from the flexibility and the extended list of capabilities and features that are possible on the client side which enabled developers to build fully-fledged applications loading on the browser in a matter of seconds, a feat that wasn't possible (or very cumbersome) in the days of the static web.

With this expansion in possibilities, came the concept of managing state, as complexity grows in client side applications, the need of keeping local state grows to become a bottleneck in itself if not handled correctly and conceived with scalability in mind.

This issue was addressed by many frameworks, following different approaches and focusing on different sets of subproblems, that is why it's important to have a high level understanding of the ecosystem of the framework of choice to assess each application's needs and employ the right approach following those metrics. This article will give you a brief overview of the common state management issues and try to introduce different approaches (useState, Context API) as a response to it. While this article will present multiple solutions, it will only focus on challenges at a smaller scale, we will cover more advanced subjects in upcoming articles.

Authentication Workflow

The code showcased throughout the article can be found here.

A live preview link can be accessed here.

Consider the case where we implement the authentication process for a React application.

User Login

As shown in the GIF above, we want to allow users to login or sign up to our application using credentials. If valid credentials were provided, the user will be logged in, the application will automatically navigate to the home page and the user can proceed to use the application.

Similarly, If the user logs out, the home page resources will be protected behind login, the login page will be the only page accessible by the user.

Thinking of this workflow in terms of implementation, we would have a main component named App, the App component will redirect the user to one of two pages: Home or Login, the current state of user ( logged in, logged out) will dictate which page the user is redirected to, a change in the current state of the user (for example changing from logged in to logged out) should trigger an instant redirection to the corresponding page.

State

As shown in the illustration above, we want the App component to consider the current state and only render one of the two pages – Home or Login – based on that current state.

If the user is null, it means we don’t have any authenticated user, so we navigate to the login page automatically and protect the home page using conditional rendering. If the user exists, we do the opposite.

Now that we have a solid understanding of what should be implemented, let's explore a few options, but first let’s set up our React project,

The project repo contains a sample backend application that we will use to implement the frontend side (we won't go into it as it's not the main focus here but the code was intentionally kept simple so you won't have a hard time with it)

We start by creating the following pages and components:

  • Home Page
  • Login Page
  • EditUser Page
  • Navbar Component
  • UserDropdown Component

A React application with multiple pages needs proper navigation, for that we can use the react-router-dom to create a global browser router context and register different React routes.


yarn add react-router-dom

We know many readers prefer following along with tutorials, so here’s the starter template to get you up to speed. This starter branch uses DaisyUI for predefined TailwindCSS JSX components. It includes all components, pages and the router already setup. If you’re considering following along with this tutorial, building the whole auth flow by yourself following straightforward steps, start by forking the repository first. After you fork the repository, clone it and start from the start-herebranch:


git clone git@github.com:<your-username>/fullstack-resourcify.git

Once you pull the start-here branch:

  • Open the project with your preferred code editor
  • Change directory to frontend/
  • Install dependencies: yarn
  • Start a development server: yarn dev·

The preview should look something like this:

Changing user name

The name rendered in the Navbar – top right side – is a state variable defined in the main App component. The same variable is passed to both Navbar and Home page. The simple form used above actually updates the “name” state variable from the EditPage component.

The two approaches presented below will go into the details of implementation:

First Approach: useState

useState()

useState is one of the most commonly used React hooks, it allows you to create and mutate state in a React Functional component. The useState component has a really simple implementation and is easy to use: to create a new state, you need to call useState with the initial value of your state and the useState hook will return an array containing two variables: the first one is the state variable that you can use to reference your state, and the second one a function that you use to change the value of the state: pretty straightforward.

How about we see that in action? The name rendered in the Navbar – top right side – is a state variable defined in the main App component. The same variable is passed to both Navbar and Home page. The simple form used above actually updates the “name” state variable from the EditPage component. Bottom line is this: useState is a core hook that accepts an initial state as a parameter and returns two variables holding two values, the state variable containing the initial state, and a setter function for the same state variable.

Let’s break it down, and see how that was implemented in the first place.

  1. Creating the “name” state variable: ./src/App.jsx

import { useState } from "react";

function App() {
const testValue = "CLA";
 //using the useState hook to create a state variable out of an initial value passed as an argument
 const [name, setName] = useState(testValue);
 console.log(`Rendering: ${name}`);

 return (...)};

Props

Props are one of the basic building blocks of a react component, conceptually, if you think of a React functional component as a Javascript function, then props are no more than the function parameters, combining props and the useState hook can offer you a solid framework for managing state across a simple React application.

React props are passed as attributes to custom components. Attributes passed as props can be destructured from the props object when accepting it as an argument, similar to this:

Passing props

<Routes>
  <Route path="/" element={<Home name={name} />} /> // Passing name as a prop to
  Home Component
  <Route
    path="/user"
    element={<EditUser name={name} setName={setName} />} // passing both name and setItem function as props to EditUser component
  />
  <Route path="/login" element={<Login />} />
</Routes>

Props can be accepted and used inside a functional component similar to normal function arguments. It’s because “name” is passed as a prop to the Home component, that we can render it in the same component. In the following example, we are accepting the passed prop using the destructuring syntax to extract the name property from the props object.

Accepting props

./src/pages/Home.jsx

... ...
export default function Home({name}) { //Destructuring the name property from the props object, another approach would be: Home(props.name)
 console.log("Rendering: Home");
 return (
   <div className="flex flex-col bg-white m-auto p-auto">
     <h1 className="flex py-5 lg:px-20 md:px-10 mx-5 font-bold text-2xl text-gray-800">
       Welcome {name}
     </h1>
... ...

Pro Tip

Open up the browser’s console, and notice how all components using the “name” prop are re-rendering when the state changes. When manipulating a state variable, React will store the next state, render your component again with the new values, and update the UI.

Components Re-rendering

useState Drawbacks

Props-Drilling

Props drilling is a term to refer to a hierarchy of components where a set of components need certain props provided by a parent component, a common workaround that inexperienced developer usually use is to pass these props throughout the entire chain of components, the issue with this approach is that a change in any of these props will trigger the entire chain of components to re-render, effectively slowing down the entire application as a result of these unnecessary renders, the components in the middle of the chain that do not require these props act as mediums for transferring props.

Example:

  • A state variable was defined in the main App component using the useState() hook
  • A name prop was passed to the Navbar component
  • Same prop accepted in Navbar and passed as prop once more to UserDropdown component
  • UserDropdown is the last child element that accepts the prop.

Prop-drilling

./src/App.jsx

... ...
function App() {
 const [name, setName] = useState(test);
 console.log("Rendering: App");

 return (
   <BrowserRouter>
     <div className="h-screen">
       <Navbar name={name} />

       <main className="px-4">
... ...

./src/components/Navbar.jsx

import React from "react";
import Logo from "../assets/cla.svg";
import { BiSearchAlt } from "react-icons/bi";
import { Link } from "react-router-dom";
import UserDropdown from "./UserDropdown";

export default function Navbar({ name }) {
  console.log("Rendering: Navbar");

  return (
    <>
      <div className="navbar bg-base-100 drop-shadow-sm">
        <div className="flex-1">
          <Link
            to="/"
            className="btn btn-ghost normal-case text-md md:text-xl px-2 gap-1"
          >
            <img src={Logo} className="h-6" alt="" />
            Resources
          </Link>
        </div>

        <UserDropdown name={name} />
      </div>
    </>
  );
}

Imagine how hard it is to maintain a React app with different layers of components all passing and rendering the same state.

Growing Complexity and Code Quality

With the use of useState and props as the only means of state management in a react application, the codebase can quickly grow in complexity, having to manage tens or hundreds of state variables, which can be duplicates of each others, scattered across different files and components can be quite daunting, any change to a given state variable will require careful consideration of dependencies between components to avoid any potential additional re rendering in an already slow application.

Second Approach: Context API

The Context API is React's attempt to solve the drawbacks of only using props and useState for state management, particularly, the context API comes as an answer to the issue previously mentioned regarding the need to pass props down the entire component tree. With the use of context, you can define a state for data that you deem global and access its state from any point in the component tree: no more prop-drilling.

It's important to point out that the context API was initially conceived to solve the issue of global data sharing, data such as UI themes, authentication information which is our use case, languages and such), for other types of data that needs to be shared amongst more than one component but is not necessarily global to all the application, context might not be the best option, depending on the use case, you might consider other techniques such as component composition which is out of the scope of this article.

Switching to Context

Creating the Auth context

createContext is straightforward, it creates a new context variable, it takes in a single optional parameter: the default value of the context variable.

Let’s see this in action, first create a new folder “contexts” and a new file inside of it “Auth.jsx”. To create a new context, we need to invoke the createContext() function, assign the returned value to a new variable Auth which will be exported next:

./src/contexts/Auth.jsx

import { createContext } from "react";

export const Auth = createContext();

Provide the Auth Context

Now we need to expose the "Auth" context variable that we created previously, to achieve this we use the context provider, the context provider is a component that has a "value" prop, we can use this prop to share a value – name – across the component tree, by wrapping the component tree with the provider component we make that value accessible from anywhere inside the component tree, without the need to pass down that prop to each child component individually.

In our authentication example, we need a variable to hold the user object, and some kind of setter to manipulate that state variable, this is a perfect use case for useState. When using Context, you need to make sure you’re defining the data you want to provide, and passing that data – user in our example – to all the component tree nested inside, so define a new state variable user inside the provider component and finally we pass both user and setUser inside an array as the value that the provider will expose to the component tree:

./src/contexts/Auth.jsx

import { createContext, useState } from "react";

export const Auth = createContext();

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  return <Auth.Provider value={[user, setUser]}>{children}</Auth.Provider>;
};

Another thing we can do is move our “name” state variable from the main app component to the Auth context and expose it to nested components:

import { createContext, useState } from "react";

export const Auth = createContext();

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [name, setName] = useState("CLA");
  return <Auth.Provider value={[name, setName]}>{children}</Auth.Provider>;
};

Now all that is left is nesting our app into the same AuthProvider component we just exported.

./src/main.jsx:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { AuthProvider } from "./contexts/Auth";

import "./index.css";

ReactDOM.createRoot(document.getElementById("root")).render(
  <AuthProvider>
    <App />
  </AuthProvider>
);

Because we are rendering the children prop inside Auth.Provider, all elements that are nested inside the AuthProvider component are now able to consume the value prop we passed to Auth.Provider. It might seem confusing, but once you experiment with it, try providing and consuming a global - Context - state. After all, It only made sense to me after experimenting with the Context API.

Consuming the Auth Context

The final step is straightforward, we use the context hook "useContext" to access the value that the context provider of "Auth" is providing, which in our case is the array containing user and setUser, in the following code, we are able to use useContext to consume the Auth context inside the Navbar. This is only possible because Navbar is nested inside the App component, and since AuthProvider wraps around the App component, the value prop can be consumed using the useContext hook only. Another awesome tool React provides out of the box to manage any data that can be accessed globally and also manipulated by any consumer component.

./src/components/Navbar.jsx

export default function Navbar() {
 const [name, setName] = useContext(Auth);
 console.log(name)

 return (...)};

Notice how we’re not accepting any props anymore in the Navbar() functional component. We’re using useContext(Auth) to consume the Auth context instead, grabbing both name and setName. This also means that we don’t need to pass the prop to Navbar anymore:

./src/App.jsx

// ... //
return (
   <BrowserRouter>
     <div className="h-screen">
       <Navbar/> // no need to pass prop anymore
// ... //

Updating the Auth Context

We can also use the provided setName function to manipulate the “name” state variable:

./src/pages/EditUser.jsx

export default function EditUser() { // no need to accept props anymore
 const [name, setName] = useContext(Auth); // grabbing the name and setName variables from Auth context
 console.log("Rendering: EditUser");

Introducing useReducer() hook

In the previous example we used the context API to manage and share our state down the component tree, you might have noticed that we are still using useState as the base of our logic, and this is mostly ok as our state is still a simple object at this point but if we were to expand the capabilities of our authentication flow we will definitely need to store more than just the email of the user currently logged in, and this is where we revert back to the limitations we previously went into in regards to the use of useState with complex state, fortunately, React resolves this issue by providing an alternative to useState for managing complex state: enter useReducer.

useReducer can be thought of as a generalized version of useState, it takes two parameters: a reducer function and an initial state.

Nothing interesting to note out for the initial state, the magic happens inside the reducer function: it checks for the type of action that occurred, and depending on that action, the reducer will determine what updates to apply to the state and return its new value.

Looking into the code below, the reducer function has two possible actions type:

  • "LOGIN": in which case the user state will get updated with the new user credentials provided inside the action payload.
  • "LOGOUT": in which case the user state will be removed from the local storage and set back to null.

It's important to note that the action object contains both a type field which determines what logic to apply and an optional payload field to provide data that is necessary for applying that logic.

Finally, the useReducer hook returns the current state and a dispatch function which we use to pass an action to the reducer.

For the rest of the logic, it's identical to the previous example:

./src/contexts/Auth.jsx

import { createContext, useEffect, useReducer, useState } from "react";

export const Auth = createContext();
import { createContext, useReducer } from "react";

export const Auth = createContext();
const reducer = (state, action) => {
  switch (action.type) {
    case "LOGIN":
      return { user: action.payload };

    case "LOGOUT":
      localStorage.removeItem("user");
      return { user: null };

    default:
      break;
  }
};

export const AuthProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, {
    user: null,
  });

  return (
    <Auth.Provider value={{ ...state, dispatch }}>{children}</Auth.Provider>
  );
};

Dispatching actions instead of using the setState setter function – eg: setName –

As we just mentioned, we use the dispatch function to pass an action to the reducer, in the following code, we initiate a LOGIN action and we provide the user email as a payload, now the user state will get updated and this change will trigger a re-render of all components subscribed to the user state. It's important to point out that a re-render will only be triggered if an actual change in the state occurs, no re-renders if the reducer returns the same previous state.

export default function Login() {
 const { dispatch } = useContext(Auth);


 const handleLogin = async (e) => {
   // Updating the global Auth context
   dispatch({ type: "LOGIN", payload: {email: email.current.value} });
 };

 return (...)};

User Login

JWT Local storage

Pro Tip

Notice how the user object we receive after a successful login is now stored in the localStorage.

Custom hook for Login

Now that we have a good grip on useReducer, we can further encapsulate our login and logout logic into their own separate custom hooks, by a single call to the Login hook, we can handle an API call to the login route, retrieve the new user credentials and store them in the local storage, dispatch a LOGIN call to update the user state, all while dealing with error handling:

./src/hooks/useLogin.jsx

import { useContext, useState } from "react";
import { Auth } from "../contexts/Auth";

export const useLogin = () => {
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(false);

  const { dispatch } = useContext(Auth);

  const login = async (email, password) => {
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch("/api/users/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });

      const json = await response.json();

      if (json.name === "Error") {
        setError(json.message);
        setIsLoading(false);
      }

      if (!response.ok) {
        setIsLoading(false);
        setError(json);
      }

      if (response.ok) {
        // Save the user and token in the localstorage
        localStorage.setItem("user", JSON.stringify(json));

        // Updating the global Auth context
        dispatch({ type: "LOGIN", payload: json });

        setIsLoading(false);
      }
    } catch (error) {
      console.log(error);
    }
  };

  return { error, isLoading, login };
};

Note: for more advanced React users among the readers, you might be wondering why didn't we use the lazy initialization feature of useReducer to retrieve the user credentials, useReducer accepts a third optional parameter called an init function, this function is used in case we need to apply some logic before we can get the initial value of the state, the reason we didn't opt for this is a simple matter of separation of concerns, the code this way is simpler to understand and as a result simpler to maintain.

Login Page

Here’s how the top part of our Login page looks like after using the useLogin() hook to extract the login function, and invoking the login function with credentials submitted by a user:

// ... ... //
export default function Login() {
 const { login, isLoading, error } = useLogin();
 console.log("Rendering: Login");
 const email = createRef(null);
 const password = createRef(null);

 const handleLogin = async (e) => {
   await login(email.current.value, password.current.value);
 };
 return (...)
// ... ... //

We’re also disabling the Submit function when the user submits the form:

<button
  onClick={handleLogin}
  disabled={isLoading}
  className="btn btn-square w-full bg-gray-100 text-gray-600 hover:bg-gray-300 border-none"
>
  {isLoading && "A moment please!"}
  {!isLoading && "Login"}
</button>

And rendering any authentication errors we receive from our backend:

{
  error && <span className="text-red-500 p-2">{error.message}</span>;
}

Rendering Errors

Maintaining the user state

You might be wondering why we need to store the user object in the localStorage, simply put, we want to keep the user signed in as long as the token is not expired. Using localStorage is a great way to store bits of JSON, just like in our example. Notice how the state get’s wiped out if you refresh the page after logging in. This can be resolved easily using a useEffect hook to check whether we have a stored user object in localStorage, if there is one, we login the user automatically:

// ... ... //
export const AuthProvider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, {
    user: null,
  });

  useEffect(() => {
    const user = JSON.parse(localStorage.getItem("user"));

    if (user) {
      return dispatch({ type: "LOGIN", payload: user });
    }
  }, []);

  return (
    <Auth.Provider value={{ ...state, dispatch }}>{children}</Auth.Provider>
  );
};

Custom hooks for Logout

Same thing applies with the Logout hook, here we are dispatching a LOGOUT action to remove the current user credentials from both the state and the local storage:

./src/hooks/useLogout.jsx

import { useContext } from "react";
import { Auth } from "../contexts/Auth";

export const useLogout = () => {
  const { dispatch } = useContext(Auth);

  const logout = () => {
    // delete user from the localstorage
    localStorage.removeItem("user");
    // Wipe out the Auth context (user:null) / dipatch 'LOGOUT'
    dispatch({ type: "LOGOUT" });
  };

  return { logout };
};

User Logout

To log out a user, let’s add a click event to the Logout button found in UserDropdown.jsx, and handle it accordingly:

./src/components/UserDropdown.jsx

// Extracting the logout function from useLogout() and handling the click event listener //
export default function UserDropdown() {
 const { user } = useContext(Auth);
 const { logout } = useLogout();
 console.log("Rendering: UserDropdown");

 const handleLogout = () => {
   logout();
 };
// ... ... //

// Adding a click event listener to logout button //
<li>
   <button onClick={handleLogout}>Logout</button>
</li>
// ... ... //

User Logout

Protecting React Routes

The final step in implementing our application is to leverage the global user state to control user navigation, a quick reminder about what behavior should we achieve: initially the user is greeted by the login page, from that point the user can access the home page only after a successful login, similarly the user will be redirected to the login page upon a logout.

We achieve this with the help of the react-router-dom library by defining 2 routes: "/" and "/login", we control which component to render at each route using the global auth state, auth evaluating to null represents an un authenticated user and vice-versa:

./src/App.jsx

import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import "./App.css";
import Navbar from "./components/Navbar";
import Home from "./pages/Home";
import Login from "./pages/Login";
import { useContext } from "react";
import { Auth } from "./contexts/Auth";

function App() {
  const { user } = useContext(Auth);
  return (
    <BrowserRouter>
      <div className="h-screen">
        <Navbar />
        <main className="px-4">
          <Routes>
            <Route
              path="/"
              element={user ? <Home /> : <Navigate to="/login" />}
            />
            <Route
              path="/login"
              element={!user ? <Login /> : <Navigate to="/" />}
            />
          </Routes>
        </main>
      </div>
    </BrowserRouter>
  );
}

export default App;

Recap Diagram

Diagram

Wrap-up

In this article we tried to tackle the simple but very common use case of implementing state management for an authentication workflow, going through the different approaches, the rationale behind each one and their tradeoffs. State management in client side frameworks and in React in particular is one of the most talked about topics in the frontend community, simply because it can either make or break the performance and the scalability of your application. The sheer amount of the different techniques, patterns, libraries and tools that try to solve this issue of state management is overwhelming, our goal was to give you a solid understanding of the practices so you can implement it in your own application, for more advanced techniques and patterns of state management in more complex applications, check out our next article where we get into redux for scalable state management in React.

Coming soon

ContextAPI vs Redux Toolkit

Redux had many years of adoption in the community before the Redux toolkit, in fact, RTK was introduced as a starter template to bootstrap redux state management in new applications (its initial name was “redux-starter-kit” in October 2019), although today, there is a general consensus between the Redux maintainers and the community that Redux toolkit is the valid way to work with redux.RTX abstracts a lot of the Redux logic which makes it easier to use, with far less verbose, and encourages developers to follow the best practices, one main difference between the two is that Redux was built to be un-opinionated, providing a minimal API and expecting developers to do most of the heavy lifting by writing their own libraries for common tasks and dealing with code structure, this resulted in slow development time and messy code, Redux toolkit was added as an extra layer of abstraction preventing developers to fall into its common pitfalls, refer to the official documentation for more insights and the reasoning from its maintainers here.

Our website uses cookies and similar technologies to personalize your experience, offer sign-on options, and to analyze our traffic. See our Privacy Policy for more info.