Written by Madars Bišs✏️
State management is the core of any modern web application, as it determines what data is displayed on the screen during the app usage session while the user interacts with it.
For example, ticking checkboxes in online surveys, adding products to a shopping cart on an e-commerce store, or selecting audio from a playlist in a music player is possible thanks to state management keeping track of each action the user makes.
In this article, we will review many state management methods that you can use to keep track of the states in your Next.js applications. For each solution, I will provide a practical example so that it is easy 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. You can think of it as 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 on the screen.
Regardless of how the application manages its data, the state must always be passed down from the parent element to the children elements.
Understanding Next.js file structure
Because Next.js is a framework, it follows a specific file structure. To review different ways to store the states, we first need to understand how the Next.js file system is built.
If you run npx create-next-app project-name
in your terminal, it will create a fully working Next.js application that consists of four main blocks: root level, then the pages
, public
, and styles
folders in it.
For managing states, we will only use pages
and the two files inside it: _app
and index.js
. The first is the root file for the entire application, where all the globally accessed components are configured. The latter is the base route file for the Home
component.
There is also an api
folder inside the pages
folder that is built in and how Next.js handles the creation of API endpoints, allowing them to receive requests and send responses. We will work with this folder toward the end of the tutorial.
Using the useState
Hook for state management in Next.js
One of the most common ways to manage state is with the useState
Hook. We will build an application that lets you increase the score by clicking the button.
Navigate into pages
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, then set the initial state to be 0
. We also provided a setScore
function so we can update the score later.
Then we created the function increaseScore
, which accesses the current value of the score and uses 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 increases.
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 multiply 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 for state management in Next.js
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 there 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 need them.
Using the Context API in Next.js
The useState
and useReducer
Hooks, combined with the prop drilling technique, will cover many use cases for most of the basic apps you build.
But what if your app is way more complex, the props need to be passed down multiple levels, or you have some states that need to be accessible globally?
Here, it is recommended to avoid prop drilling and use the 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, 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 entire 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 useThemeContext
, then accessed the theme
state and the 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
Next.js uses a 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 the Link
component, it will be accessible via /route
in your URL.
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 does 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. We’ll 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 unsuccessful. It lets us display the error message, giving us further information about the cause.
The Fetch API and the useEffect
Hook
One of the most common ways to handle 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 has 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 the api
folder and create a new file called book.js
inside it, so we get the API endpoint we included in 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 be fine for this tutorial.
Using SWR for state management in Next.js
There is also an alternate method, created by the Next.js 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, re-fetching 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.
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 many things: the syntax looks cleaner and is easier to read, it’s well suited for scalability, and errors and response states get handled in a couple of lines of code.
Now, let's create the API endpoint so we get the 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 publicly available names.
Conclusion
In this tutorial, we built several mini-applications to showcase many of the ways we can manage state in Next.js apps.
Each use case used different state management solutions, but the biggest challenge for picking the most appropriate state management solution is the 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.
LogRocket: Full visibility into production Next.js apps
Debugging Next applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Top comments (0)