📅 3 years ago 🕒 14 min read 🙎♂️ by Madza
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.
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.
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.
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 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.
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 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.
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.
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.
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.
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.
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.