Editor’s note: This article was reviewed for accuracy on 16 March 2024 by Elijah Asaolu and updated to cover more recent developments in the React and Next.js ecosystem relevant to state management.
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, consider features like ticking checkboxes in online surveys, adding products to a shopping cart on an ecommerce store, or selecting audio from a playlist in a music player. These are all 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 states in your Next.js applications.
We’ll start by looking at common state management techniques in traditional React.js and how to implement them in Next.js. Then, we’ll look at more complex state management techniques, such as middleware, and how state management is handled in React Server Components (RSCs).
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.
A state is a JavaScript object that holds the current status of some 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. As a result, 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.
Before diving into the file structure, let’s create a new Next.js application. Open your terminal and run the following command, replacing project-name
with your desired project name:
npx create-next-app@latest <project-name>
This command will create a new project directory named <project-name>
for us. During installation, you’ll be presented with an interactive menu where you can select a preferred Next.js version, decide if you want to use TypeScript for type safety, select a pre-configured CSS framework like Bootstrap or Tailwind CSS, and more.
Once the installation is completed, your project will have a specific file structure that streamlines development. Let’s explore the key folders and their purposes:
package.json
and next.config.js
app
directory (introduced in Next 13): Next 13 introduces the app
directory, which houses all your application logic, including components, layouts, and routes. This directory replaces the previous pages
directory for better organization and scalabilitypublic
directory: This folder stores static assets like images, fonts, and favicons that are directly accessible during build timestyles
directory: This directory holds global CSS styles that apply throughout your applicationpages
directory (optional in Next 13): While the app
directory takes center stage in Next 13, the pages
directory remains usable for legacy projects or situations where you prefer file-based routing. However, for newly created applications, using the app
directory is recommendedIf you’re using the app
directory and your component interacts with the browser environment (e.g., utilizes Hooks like useState
or useEffect
for client-side interactions), you need to include use client
at the top of the component file. This instructs Next.js to treat the component as a client-side component, ensuring it renders and functions correctly within the browser.
Let’s begin with the basics of Next.js state management. In the upcoming sections, we’ll talk about basic Hooks, techniques, and tools we can use to manage state efficiently.
useState
Hook for state management in Next.jsThe useState
Hook is a popular method for managing state in traditional React applications, and it works similarly in Next.js. To get started, let’s create an application that allows users to increase their score by clicking a button.
Navigate into pages
and include the following code in index.js
:
// 'use client' // if using /app folder 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 + button, so each time the button is pressed, the score increases.
useReducer
HookThe 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
:
// 'use client' // if using /app folder 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.
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:
// 'use client' // if using /app folder 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.
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:
// 'use client' // if using /app folder 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.
Context
via routesNext.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:
// 'use client' // if using /app folder 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.
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.
useEffect
HookOne 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 Next.js, transform the index.js
to the following:
// 'use client' // if using /app folder 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. 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.
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 SWR, run npm install swr
in your terminal. To see it in action, let’s transform the index.js
file:
// 'use client' // if using /app folder 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.
Middleware in Next.js is a powerful feature for managing requests and responses throughout your application. It allows you to run code before a request is completed, giving you a flexible way to manipulate requests and responses and effectively manage global states.
To get started with middleware, create a middleware.js
file within the /pages
or /app
directory of your Next.js application. This file is where you’ll define your middleware logic.
Here’s a basic setup:
import { NextResponse } from 'next/server'; export function middleware(request) { // Your middleware logic goes here return NextResponse.next(); }
Middleware shines in scenarios where you need to manage user authentication states globally. For instance, you can check if a user is authenticated and redirect unauthenticated users to a login page before rendering protected routes.
import { NextResponse } from 'next/server'; export function middleware(request) { const { pathname } = request.nextUrl; // Assuming you have a method to verify authentication if (!isUserAuthenticated() && pathname.startsWith('/protected')) { return NextResponse.redirect('/login'); } return NextResponse.next(); }
The example above identifies the route the user is trying to visit via request.nextUrl.pathname
. Then, we use a hypothetical isUserAuthenticated()
code to check if the user is authenticated.
If the user is not authenticated and attempts to access a route beginning with /protected
, NextResponse.redirect('/login')
redirects them to a login page. However, if they are authorized or visiting a non-protected route, the middleware permits the request to continue normally with NextResponse.next()
.
This centralized approach ensures that the authentication state is consistently applied across all protected parts of your application.
Another common use case is managing theme settings based on user preferences or system settings. Middleware allows you to intercept requests and modify response headers or cookies to reflect the user’s preferred theme:
import { NextResponse } from 'next/server'; export function middleware(request) { const preferredTheme = request.cookies.get('theme') || 'light'; // Modify the response to include the preferred theme const response = NextResponse.next(); response.cookies.set('theme', preferredTheme); return response; }
In this example, we check for the user’s preferred theme ('theme')
in the cookies and fall back to 'light'
if it is not found.
Before making any request, we updated the response to include the desired theme in the cookies by calling response.cookies.set('theme', preferredTheme)
. The response, which now contains the user’s theme preference, is returned, allowing your application to use and manage the theme state throughout the user’s session.
Next 13 introduced Server Components, a new way to create high-performance applications. These components run on the server during the initial request, providing benefits such as faster initial page loads and improved SEO.
However, state management in server components differs from typical React client components. Before we get into these state management techniques, let’s take a quick look at how to create server components and how they differ from traditional client components.
If you use the new /app
directory for routing, components are treated as server components by default, which means they run on the server during the initial request. Here’s a basic example of a server component that queries data from a database and renders it:
import { dbConnect } from '@/services/mongo'; import TodoList from './components/TodoList'; export default async function Home() { await dbConnect(); const todos = await dbConnect().collection('todos').find().toArray(); return ( <main> <TodoList todos={todos} /> </main> ); }
As shown in the example above, we have direct access to the server process and can create an async component. We were also able to connect to a MongoDB database directly from the component, which is something that is peculiar to server frameworks and languages like Express and PHP.
This is the brilliance of server components. However, this also means that we cannot use client-side Hooks such as useState()
and useEffect()
in these components.
While server components dominate in the /app
router, you might still need client-side components that interact with the browser environment. To create a traditional client-side component, simply include the 'use client'
statement at the top of the component file, as shown below:
'use client' import { useState} from 'react'; export default function MyClientComponent() { const [count, setCount] = useState(0); const handleClick = () => setCount(count + 1); return ( <div> <p>You clicked {count} times</p> <button onClick={handleClick}>Increment</button> </div> ); }
This way, you have access to your traditional hooks as usual and can perform client-side interactivity.
As previously stated, server components cannot directly use hooks such as useState()
or useEffect()
, however you can use libraries such as Zustand for state management, even on the server.
Nonetheless, it is generally not recommended to manage complex application states on the server due to potential performance implications and data inconsistencies. This Github discussion provides additional insights in this regard.
Adopt server-side storage mechanisms such as cookies or sessions for stateful data that must be persisted across user sessions. These are ideal for handling authentication states, user preferences, or data that does not change regularly. For example, you can access user cookies in a server component, shown below:
import { cookies } from 'next/headers' export default function Page() { const cookieStore = cookies() return cookieStore.getAll().map((cookie) => ( <div key={cookie.name}> <p>Name: {cookie.name}</p> <p>Value: {cookie.value}</p> </div> )) }
Additionally, you can leverage Server Actions to set or delete cookies, as shown below:
'use server' import { cookies } from 'next/headers' async function create(data) { cookies().set('name', 'John Doe') } async function delete (data) { cookies().delete('name') }
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.
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking 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.js 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.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]
4 Replies to "Understanding state management in Next.js"
Nice, simple and helpful article.
I have one suggestion. In the first example I would recommend using the
setState(prevstate => prevstate + 1)
syntax , because in more complex scenarios, where the setState is called from multiple places, the result not always what we expect. Although in this simple Example it will work perfectly fine.
I think i’m missing something. How is any of this unique to NextJS? Most of this is just React specific hooks?
well nextjs is built on top of react and most of the things are common, It just adds some more optimization logic that is all.
Would you not need to put “use client” at the top of the file as it is a client component?