Understanding State Management in NextJS

📅 2 years ago 🕒 14 min read 🙎‍♂️ by Madza

Understanding State Management in NextJS

State management is the core of any modern web application since it determines what data is displayed on the screen during the app usage session while the user interacts with it.

Think of ticking checkboxes on online surveys, adding products to a shopping cart on an e-commerce store, or selecting audio from a playlist in a music player. It is all possible due to keeping track of each action the user makes.

In this article, we will review many different state management methods that you can use to keep track of the states in your NextJS applications. For each solution, I will provide a practical example, so it is easier to understand how each approach works.

We will use the top to bottom approach, reviewing the simplest methods first and moving into more advanced solutions for more complex use cases.

How does a state work?

A state is a JavaScript object that holds the current status of the data. A good real-life comparison would be a light switch, that can have either On or Off states. Now, transfer the same principle to the React ecosystem and imagine the use of the Light and Dark mode toggle.

Each time the user clicks on the toggle the opposite state is being activated. That state is then being updated in the Javascript state object, so your application knows which state is currently active and what theme to display to the screen.

Regardless of how the application manages its data, the state must always be passed down from the parent element to the children element.

NextJS file structure

Since NextJS is a framework, it follows a specific file structure. In order to review different ways to store the states, we first need to understand how the NextJS file system is built.

If you run npx create-next-app project-name in your terminal, it will create a fully working NextJS application, that consists of 4 main blocks: root level and the pages, public, and styles folders in it.

For managing states, we will only use pages folder and the two files inside it: _app and index.js. The first is the root file for the whole application, where all the globally accessed components are usually configured. The latter is the base route file for the Home component.

There is also an api folder inside the pages folder and that is a built-in way on how NextJS handles the creation of API endpoints, allowing them to receive requests and send responses. We will work with this folder towards the end of the tutorial.

The useState hook

One of the most common ways for state management is the useState hook. We will build an application that lets you increase the score by clicking the button.

Navigate into pages folder and include the following code in index.js:

import { useState } from "react";

export default function Home() {
  const [score, setScore] = useState(0);
  const increaseScore = () => setScore(score + 1);

  return (
    <div>
      <p>Your score is {score}</p>
      <button onClick={increaseScore}>+</button>
    </div>
  );
}

We first imported the useState hook itself, and then set the initial state to be 0. We also provided a setScore function so we can update the score later.

Then we created a function increaseScore, that accesses the current value of the score and use the setState to increase that by 1. We assigned the function to the onClick event for the plus button, so each time the button is pressed the score is increased.

The useReducer hook

The useReducer hook works similarly to the reduce method for arrays. We pass a reducer function and an initial value. The reducer receives the current state and an action and returns the new state.

We will create an app that lets you multiple the currently active result by 2. Include the following code in index.js:

import { useReducer } from "react";

export default function Home() {
  const [multiplication, dispatch] = useReducer((state, action) => {
    return state * action;
  }, 50);
  return (
    <div>
      <p>The result is {multiplication}</p>
      <button onClick={() => dispatch(2)}>Multiply by 2</button>
    </div>
  );
}

First, we imported the useReducer hook itself. We passed in the reducer function and the initial state. The hook then returned an array of the current state and the dispatch function.

We passed the dispatch function to the onClick event so that the current state value gets multiplied by 2 each time the button is clicked, setting it to the following values: 100, 200, 400, 800, 1600 and so on.

The prop drilling technique

In more advanced applications, you will not work with states directly in a single file. You will most likely divide the code into different components so it is easier to scale and maintain the app.

As soon as they are multiple components the state needs to be passed from the parent level to the children. This technique is called prop drilling and it can be multiple levels deep.

For this tutorial, we will create a basic example just two levels deep to give you an idea of how the prop drilling works. Include the following code to the index.js file:

import { useState } from "react";

const Message = ({ active }) => {
  return <h1>The switch is {active ? "active" : "disabled"}</h1>;
};

const Button = ({ onToggle }) => {
  return <button onClick={onToggle}>Change</button>;
};

const Switch = ({ active, onToggle }) => {
  return (
    <div>
      <Message active={active} />
      <Button onToggle={onToggle} />
    </div>
  );
};

export default function Home() {
  const [active, setActive] = useState(false);
  const toggle = () => setActive((active) => !active);

  return <Switch active={active} onToggle={toggle} />;
}

In the code snipped above the Switch component itself does not need active and toggle values, but we have to "drill" through the component and pass those values to the children components Message and Button that needs them.

The use of Context API

The useState and useReducer hooks combined with the prop drilling technique will cover the majority of the use cases for most of the basic apps you build.

But what if your app gets way more complex, the props need to be passed down multiple levels, or you have some states that need to be accessible globally?

In this case, it is recommended to avoid prop drilling and use Context API, which will let you access the state globally.

It's always a great practice to create separate contexts for different states like authentication, user data, and so on. We will create an example for theme state management.

First, let's create a separate folder in the root and call it context. Inside it create a new file called theme.js and include the following code:

import { createContext, useContext, useState } from "react";

const Context = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");
  return (
    <Context.Provider value={[theme, setTheme]}>{children}</Context.Provider>
  );
}

export function useThemeContext() {
  return useContext(Context);
}

We first created a new Context object, created a ThemeProvider function, and set the initial value for the Context to 'light'.

Then we created a custom useThemeContext hook that will allow us to access the theme state after we import it into the individual pages or components of our app.

Next, we need to wrap the ThemeProvider around the whole app, so we can access the state of the theme in the entire application. Head to the _app.js file and include the following code:

import { ThemeProvider } from "../context/theme";

export default function MyApp({ Component, pageProps }) {
  return (
    <ThemeProvider>
      <Component {...pageProps} />
    </ThemeProvider>
  );
}

To access the state of the theme, navigate to index.js and include the following code:

import Link from "next/link";
import { useThemeContext } from "../context/theme";

export default function Home() {
  const [theme, setTheme] = useThemeContext();
  return (
    <div>
      <h1>Welcome to the Home page</h1>
      <Link href="/about">
        <a>About</a>
      </Link>
      <p>Current mode: {theme}</p>
      <button
        onClick={() => {
          theme == "light" ? setTheme("dark") : setTheme("light");
        }}
      >
        Toggle mode
      </button>
    </div>
  );
}

We first imported the useThemeContext hook and then accessed the theme state and setTheme function to update it when necessary.

Inside the onClick event of a toggle button, we created an update function that switches between the opposite values between 'light' and 'dark', depending on the current value.

Accessing Context via routes

NextJS uses pages folder to create new routes in your app. For example, if you create a new file route.js, and then refer to it from somewhere via Link component, it will be accessible via /route in your URL.

As you most likely noticed, in the previous code snippet we created a route to the About route. This will allow us to test that the theme state is globally accessible.

This route currently do not exist, so let's create a new file called about.js in the pages folder and include the following code:

import Link from "next/link";
import { useThemeContext } from "../context/theme";

export default function Home() {
  const [theme, setTheme] = useThemeContext();
  return (
    <div>
      <h1>Welcome to the About page</h1>
      <Link href="/">
        <a>Home</a>
      </Link>
      <p>Currently active theme: {theme}</p>
      <button
        onClick={() => {
          theme == "light" ? setTheme("dark") : setTheme("light");
        }}
      >
        Toggle mode
      </button>
    </div>
  );
}

We created a very similar code structure that we used in the Home route earlier. The only differences were the page title and a different link to navigate back to Home.

Now try to toggle the currently active theme and switch between the routes. Notice that the state is preserved in both routes. You can further create different components and the theme state will be accessible whenever in the app file tree it is located.

Data fetching from an API

The previous methods would work when managing states internally in the app. However, in a real-life scenario, you will most likely fetch some data from outside sources via API.

The data fetching can be summarized as making a request to the API endpoint and receiving the data after the request is processed and the response is sent.

We need to take into consideration that this process is not immediate, so we need to manage states of the response like the state of waiting time while the response is being prepared and also handle the cases for potential errors.

Keeping track of the waiting state lets us display a loading animation to improve the UX and the error state lets us know that the response was not successful, and lets us display the error message, which gives us further information about the cause.

The Fetch API and the useEffect hook

One of the most common ways to handle the data fetching is to use the combination of the native Fetch API and the useEffect hook.

The useEffect hook lets us perform side effects once some other action has been completed. With it, we can track when the app has been rendered and we are safe to make a Fetch call.

To fetch the data in NextJS, transform the index.js to the following:

import { useState, useEffect } from "react";

export default function Home() {
  const [data, setData] = useState(null)
  const [isLoading, setLoading] = useState(false)

  useEffect(() => {
    setLoading(true)
    fetch('api/book')
      .then((res) => res.json())
      .then((data) => {
        setData(data)
        setLoading(false)
      })
  }, [])

  if (isLoading) return <p>Loading book data...</p>
  if (!data) return <p>No book found</p>

  return (
    <div>
      <h1>My favorite book:</h1>
      <h2>{data.title}</h2>
      <p>{data.author}</p>
    </div>
  )
}

We first imported the useState and useEffect hooks. Then we created separate initial states for received data to null and loading time to false, indicating that no Fetch call is been made.

Once the app has been rendered, we set the state for loading to true, and create a Fetch call. As soon as the response has been received we set the data to the received response and set the loading state back to false, indicating that the fetching is complete.

Next, we need to create a valid API endpoint, so navigate to api folder and create a new file called book.js inside it, so we get the API endpoint we included it the fetch call in the previous code snippet. Include the following code:

export default function handler(req, res) {
  res
    .status(200)
    .json({ title: "The fault in our stars", author: "John Green" });
}

This code simulates a response about the book title and author you would normally get from some external API but will do fine for the purpose of this tutorial.

The use of SWR

There is also an alternate method created by the NextJS team itself, to handle data fetching in an even more convenient way.

It's called SWR - a custom hook library that handles caching, revalidation, focus tracking, refetching on the interval, and more. To install it, run npm install swr in your terminal.

To see it in action, let's transform the index.js file to the following:

import useSWR from "swr";

export default function Home() {
  const fetcher = (...args) => fetch(...args).then((res) => res.json());
  const { data, error } = useSWR("api/user", fetcher);

  if (error) return <p>No person found</p>;
  if (!data) return <p>Loading...</p>;

  return (
    <div>
      <h1>The winner of the competition:</h1>
      <h2>
        {data.name} {data.surname}
      </h2>
    </div>
  );
}

Using SWR simplifies a lot of things, the syntax looks cleaner and easier to read, and well suited for scalability. Errors and response states get handled in a couple of lines of code.

Now, let's create the API endpoint so we get some response. Navigate to the api folder, create a new file called user.js and include the following code:

export default function handler(req, res) {
  res.status(200).json({ name: "Jade", surname: "Summers" });
}

This API endpoint simulates the fetching of the name and surname of the person, which you would normally get from a database or an API containing a list of publically available names.

Conclusion

In this tutorial we built several mini-applications starting from number increaser, value multiplier, switch toggle, global theme provider and then couple apps that return data from API endpoints and track states during the request-response process.

Each of them used different state management solutions. From all of the above, we can be certain, that the biggest challenge for picking the most appropriate state management solution is your ability to identify what states you need to track.

Beginners often struggle and choose overkill solutions for managing simple states, but it gets better with time, and the only way to improve it is by practicing. Hopefully, this article helped you to take a step in the right direction towards achieving that.