Next.js – 3 ways for managing entire application state

The purpose of this document is to describe three different ways to manage state in a Next.js application.

Overview

Many modern-day web applications are broken up into a front-end project, which is usually a Single Page Application, and a back-end project, or a REST API. These types of projects are typically managed within separate git repositories. Confusion and frustration can ensue when the REST project needs to change the shape of the data it returns. Why? Because you are likely trying to orchestrate the release of both projects at nearly the same time to ensure little to no downtime, and it only sometimes works out as expected. To further complicate things, sometimes the languages used to develop the back end are different from the front end, and therefore, you may not be able to use the same developers on the front end as you would for the back end and vice versa.

Next.js is a great framework for developing web applications by allowing both the front end and back-end code to be in one project and repository. If you manage a software development team or are part of a software development team this framework could greatly improve your development work flows and releases. Next.js uses REACT for the front end, and nodejs for the back end therefore your entire project will be javascript. Since the entire project is in the same language it will make it easier for developers to be considered “full stack”. For today’s purposes we are going to discuss more about the front-end side and how to setup Next.js to track application state across any page in the Next.js application.

We will be demonstrating having a counter and modifier. The modifier will indicate how much to adjust the count by. We will then have pages that will either add, subtract, multiple, or divide the current count. No matter what page you are on the current count and current modifier will be the same until an action is executed.
At a high level. The three options to manage the state are:

  • useState
  • useReducer
  • or use the Redux Toolkit

There is not a one size fits all approach so depending on the needs of your application it will depend on the strategy that you will deploy. We will discuss some of the pros and cons about these approaches along the way. Furthermore, sharing the state can get a little more complicated when you introduce TypeScript. But it is a best practice and we will go over that here. If you would like to follow along, we are going to create a git branch for each method.

To begin, choose a directory on your computer that will be used to contain the new next.js app by executing the following command. Of course, you can choose different names and adjust accordingly. You can also just read the rest of the article or my completed git repository and tell for yourself what bits of code are important to you.

To start from scratch execute the following command:

npx create-next-app --typescript
Then enter a name for your project. I entered state-management-demos. I indicated Yes I would like ESLint, and Yes to the src directory, no to the app/ directory, and @/* for the import alias.
Change directories to the application directory that was created for you and open it in Visual Studio Code or your favorite IDE.
useState

Managing Next.js application state with useState

As you learn about React or Next.js you will commonly see examples of the useState being used in an individual component. Some other examples online will show you how to share that state with a child component. But what if this state is common across the entire application and nearly all pages or components need to know about the state? Well luckily you can use the useState that you have probably already learned about and once you add it to a context and create a provider you will be able to do that.

I would recommend creating a new branch immediately called “useState” so that we can always start each branch from the base next.js typescript template. You can use the UI in your IDE to do that or execute the following command.

git checkout -b useState
To begin we will create a new directory called “context” in the “src” directory and a new file called “AppContext.tsx” with the following code:
import React from 'react'

export interface IAppState {
    currentCount: number
    currentModifier: number
}

const useValue = () => {
    const [appState, setAppState] = React.useState<IAppState>({currentCount: 0, currentModifier: 1});

    return {
        appState,
        setAppState
    }
}

interface IAppStateContext extends ReturnType<typeof useValue> {    
}

export const AppStateContext = React.createContext<IAppStateContext>({} as IAppStateContext)

interface ContextProviderProps {
    children: JSX.Element
}

export const AppStateContextProvider: React.FC<ContextProviderProps> = (props) => {
    return (
        <AppStateContext.Provider value={useValue()}>
            {props.children}
        </AppStateContext.Provider>
    )
}
Let’s go over this code for a moment.

1 – What we are doing here is first specifying a model for our entire applications state IAppState. It has two number properties of currentCount and currentModifier. Next, we use React’s useState to set a default value and get back the current value, and the modifier function. We return those values in the useValue method.

2 – We then create an additional interface that extends the return type of the useValue. This makes it so that type will change even when we make changes to the IAppState interface without having to modify the AppStateContext interface. We then export the AppStateContext from React’s created context. For now, it is just an empty object.

3 – We then define the props that will be passed into our exported provider element. Create the provider element, and then encase the children within that component. This means that any component that will be wrapped within our AppStateContextProvider element will have access to our entire application state.

So far. So good. Now let’s create a very basic layout that has current count and current modifier at the top of the page. The modifier can be updated at will at the top of any page, and the count will be modified after pressing a button on any operations page. The layout will also have links to the operation pages (Add, subtract, etc).

In the “src” directory add a new directory called “components”. Create a new file called “Layout.tsx”.
Give it the following code:

import Link from "next/link"
import { AppStateContext } from '@/context/AppContext'
import { useContext } from "react"

interface LayoutProps {
    children: JSX.Element
}

export default function Layout({children}: LayoutProps): JSX.Element {
    const {appState, setAppState} = useContext(AppStateContext);

    return(
        <>
            <div style={{
                display: 'flex',
                flexDirection: 'row'
            }}>
                <div style={{
                    border: '1px solid black',
                    padding: '1rem'
                }}>
                    <ul>
                        <li>
                            <Link href="/">Home</Link>
                        </li>
                        <li>
                            <Link href="/add">Add</Link>
                        </li>
                        <li>
                            <Link href="/subtract">Subtract</Link>
                        </li>
                        <li>
                            <Link href="/multiply">Multiply</Link>
                        </li>
                        <li>
                            <Link href="/divide">Divide</Link>
                        </li>
                        <li>
                            <Link href="/reset">Reset</Link>
                        </li>
                    </ul>
                </div>
                <div style={{
                    border: '1px solid black',
                   padding: '1rem'
                }}>
                    Current Count: {appState.currentCount}<br />
                    Current Modifier: <input type="number" min="0" max="20" value={appState.currentModifier} onChange={(e) => {
                        setAppState({...appState, currentModifier: parseInt(e.target.value)})
                    }} /><br/>
                    {children}
                </div>
            </div>            
        </>
    )
}
The adding of the links to the left side of the page are pretty self-explanatory. The near stuff happens at the beginning and ending of this function. First, we import the AppStateContext from the AppContext component that we created earlier. Then as we are exporting this Element, we are grabbing the current appState and setAppState functions via the useContext method of React. We then use them in a number of ways. We use the currentCount property of the appState to display the current count, we then use the setAppState method to set the currentModifier when the number input is changed. So, we are already off to a great start.

This truly is the basics of using this method of managing state. We import the Context, and then use values from the context, or set the values using the setter function that is returned from the useContext method of React.

Next, we will modify the App component so that it will have access to our AppStateContext and change the layout for all pages going forward.

In the “src” directory and the “pages” directory modify the _app.tsx file as follows:

// import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { AppStateContextProvider } from '@/context/AppContext'
import Layout from '@/components/Layout'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <AppStateContextProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
    </AppStateContextProvider>
  )
}
What we have done here is imported the exported AppStateContextProvider from the AppContext file that we made earlier. It is the top-level component which now means any component under it will be able to use the useContext method that we demonstrated in the layout component. Furthermore, the layout component is the first child of our provider, and the layout takes the standard main Component, which in the context of a Next.js app means a page, which therefore now means that all pages will have access to the AppState and have the same Layout applied to it.

Next, let’s update the index.tsx file in this same directory to the following code.

import Head from 'next/head'

export default function Home() {
  return (
    <>
      <Head>
        <title>Managing AppState with useState</title>
        <meta name="description" content="Demonstrates managing entire app state with nextjs using react's built in useState method" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <header>
        <h1>Home</h1>
      </header>
      <main>
        <p>Select an action on the left to modify the current count. You can update the modifier by using the spinner next to the current modifier on this page or any other page.</p>
        <p>In this demonstration we are managing the entire app state using the useState method that comes with React.</p>
      </main>
    </>
  )
}
At this point we can run the program by pulling up a terminal and running the following command from within the folder we created the application in by using…
npm run dev
The UI is not all that impressive, but that really isn’t the point of this demonstration as we are focusing on application state but should look something like this:
At this time the only functionality that will really work is clicking up or down on the spinner in the current modifier input.
Let’s go ahead and build our add page.

In the “src\pages” directory add a new file called “add.tsx”. Give it the following code.

import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function AddPage() {
    const { appState, setAppState } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Add</title>
            </Head>
            <header>
                <h1>Add</h1>
            </header>
            <main>
                <button onClick={() => {
                    setAppState({
                        ...appState,
                        currentCount: appState.currentCount + appState.currentModifier
                    })
                }}>Add {appState.currentModifier} to the count</button>
            </main>
        </>
    )
}
In this file we are grabbing the AppStateContext again, and within the page definition we are extracting the current appState and setAppState function by using the useContext method of react and passing in the AppStateContext object. This page just has one button that will say “Add 1 to the count” when the current modifier is 1, or will print whatever the current modifier is. When the button is clicked, the count will increase and display in the Layout component that we made earlier.

Initial state:

After pressing the Modifier up a few times
After pressing the add button
And then if you go back home the state remains the same instead of being lost as in traditional tutorials that show you how to manage state on a specific page.
At this point it is all wash, rinse and repeat for the remaining pages. All that is different are a few words and the operator that you actually use in your setState function for the button.

subtract.tsx

import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function SubtractPage() {
    const { appState, setAppState } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Subtract</title>
            </Head>
            <header>
                <h1>Subtract</h1>
            </header>
            <main>
                <button onClick={() => {
                    setAppState({
                        ...appState,
                        currentCount: appState.currentCount - appState.currentModifier
                    })
                }}>Subtract {appState.currentModifier} from the count</button>
            </main>
        </>
    )
}
multiply.tsx
import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function MultiplyPage() {
    const { appState, setAppState } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Multiply</title>
            </Head>
            <header>
                <h1>Multiply</h1>
            </header>
            <main>
                <button onClick={() => {
                    setAppState({
                        ...appState,
                        currentCount: appState.currentCount * appState.currentModifier
                    })
                }}>Multiy the current count by {appState.currentModifier}</button>
            </main>
        </>
    )
}
divide.tsx
import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function DividePage() {
    const { appState, setAppState } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Divide</title>
            </Head>
            <header>
                <h1>Divide</h1>
            </header>
            <main>
                <button onClick={() => {
                    setAppState({
                        ...appState,
                        currentCount: appState.currentCount / appState.currentModifier
                    })
                }}>Divide the current count by {appState.currentModifier}</button>
            </main>
        </>
    )
}
reset.tsx
import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function ResetPage() {
    const { appState, setAppState } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Reset</title>
            </Head>
            <header>
                <h1>Reset</h1>
            </header>
            <main>
                <button onClick={() => {
                    setAppState({
                        currentCount: 0,
                        currentModifier: 1
                    })
                }}>Reset the current count to 0 and the current modifier to 1</button>
            </main>
        </>
    )
}
Practice using each page and notice the state changing. Remember you can change the modifier at any time. Notice again that as you transition the state does stay consistent until a button is pressed. This is only a client side persistence and has nothing to do with cookies or persisting the state beyond the current session. That is a whole other conversation depending on what requirements you have for persisting application state and out of scope for this article.

Press CTRL+C to stop the npm run dev that was running earlier.

If you are following along then commit your changes, otherwise read on.

useReducer

Managing Next.js application state with useReducer

If you are following along switch back to main and then create a new branch called useReducer that is based on master. Either use your IDE’s UI to do so or these commands will work. Otherwise read on.

git checkout main
git checkout -b useReducer
Using the useReducer method is very similar to the useState method that we went through earlier where we will need to create a context. And instead of using a setState function we will be using a dispatcher. Furthermore one of the biggest differences that you will see here is that the logic of modifying the state will actually be centralized to a single file, as opposed to the logic being on each button as you will see later. As such this means that you could potentially have different buttons or triggers that execute the same logic without having to repeat the same logic on each event or button. It will become a little more apparent as we get into the code. Once again working with the reducer can be more complicated with typescript than not, but as always is worth the effort.

Create a new folder in the src directory called “context”. Add a file called “AppContext.tsx”. Give it the following code.

import React from 'react'

export interface IAppState {
    currentCount: number
    currentModifier: number
}

const defaultAppState: IAppState = {
    currentCount: 0,
    currentModifier: 1
}

type IAction = 
    | { type: 'add' }
    | { type: 'subtract' }
    | { type: 'multiply' }
    | { type: 'divide' }
    | { type: 'reset' }
    | { type: 'setModifier', payload: number }
What we are doing here is setting up the things we need to introduce our reducer.
Currently we have:

  • An interface that represents our Application State
  • An object that represents the default value of our Application State
  • And some actions that we are going to send to our reducer function

Now let’s create our reducer function.

function reduer(state: IAppState, action: IAction): IAppState {
    if(action.type) {
        switch(action.type) {
            case 'add': {
                return { ...state, currentCount: state.currentCount + state.currentModifier }
            }
            case 'subtract': {
                return { ...state, currentCount: state.currentCount - state.currentModifier }
            }
            case 'multiply': {
                return { ...state, currentCount: state.currentCount * state.currentModifier }
            }
            case 'divide': {
                return { ...state, currentCount: state.currentCount / state.currentModifier }
            }
            case 'reset': {
                return defaultAppState
            }
            case 'setModifier': {
                return { ...state, currentModifier: action.payload }
            }
        }
    }

    return state;
}
In the world of reducers the function must tate in two parameters and return an object matching the expected new application state. As you can see in this example the same logic that was applied to the individual buttons in the previous example have now been condensed in to this function within a switch statement. The results are the same where it returns a new state that has taken the requested action.

We are only halfway done. We need to now use this reducer and apply it to a context.

Add the following code.

const useValue = () => {
    const [appState, updateAppStateDispatcher] = React.useReducer(reduer, defaultAppState);

    return {
        appState,
        updateAppStateDispatcher
    }
}

interface IAppStateContext extends ReturnType<typeof useValue> {    
}

export const AppStateContext = React.createContext<IAppStateContext>({} as IAppStateContext)

interface ContextProviderProps {
    children: JSX.Element
}

export const AppStateContextProvider: React.FC<ContextProviderProps> = (props) => {
    return(
        <AppStateContext.Provider value={useValue()}>
            {props.children}
        </AppStateContext.Provider>
    )
}
The useValue here is very similar to the useValue that we did last time; however, we are using the useReducer method instead of useState method. The useReducer is taking in the reducer function that we acreated earlier and the defaultAppState for it to start with. The return value of useReducer is the currentAppState and a method that can be used to dispatch the new app state. This is what our pages will be using down stream of the provider that we created in this code block as well.

Your entire AppContext.tsx file should have the following code.

import React from 'react'

export interface IAppState {
    currentCount: number
    currentModifier: number
}

const defaultAppState: IAppState = {
    currentCount: 0,
    currentModifier: 1
}

type IAction = 
    | { type: 'add' }
    | { type: 'subtract' }
    | { type: 'multiply' }
    | { type: 'divide' }
    | { type: 'reset' }
    | { type: 'setModifier', payload: number }

function reduer(state: IAppState, action: IAction): IAppState {
    if(action.type) {
        switch(action.type) {
            case 'add': {
                return { ...state, currentCount: state.currentCount + state.currentModifier }
            }
            case 'subtract': {
                return { ...state, currentCount: state.currentCount - state.currentModifier }
            }
            case 'multiply': {
                return { ...state, currentCount: state.currentCount * state.currentModifier }
            }
            case 'divide': {
                return { ...state, currentCount: state.currentCount / state.currentModifier }
            }
            case 'reset': {
                return defaultAppState
            }
            case 'setModifier': {
                return { ...state, currentModifier: action.payload }
            }
        }
    }

    return state;
}

const useValue = () => {
    const [appState, updateAppStateDispatcher] = React.useReducer(reduer, defaultAppState);

    return {
        appState,
        updateAppStateDispatcher
    }
}

interface IAppStateContext extends ReturnType<typeof useValue> {    
}

export const AppStateContext = React.createContext<IAppStateContext>({} as IAppStateContext)

interface ContextProviderProps {
    children: JSX.Element
}

export const AppStateContextProvider: React.FC<ContextProviderProps> = (props) => {
    return(
        <AppStateContext.Provider value={useValue()}>
            {props.children}
        </AppStateContext.Provider>
    )
}
For what it is worth a lot of style guides out there recommend putting the reducer at the bottom of the file. Other people also think that you should define a variable right before you use it for better readability of the file from top to bottom. I will leave that to your discretion if you want to put the reducer at the bottom of the file or not. It will work either way.

Now let’s do what we did similar to before which is to create a common layout and add the context to the app so that every page can share in this application state.

To the “src” directory create a new directory called “components” and create a new file called “Layout.tsx”. Give it the following code.

import Link from "next/link"
import { AppStateContext } from '@/context/AppContext'
import { useContext } from "react"

interface LayoutProps {
    children: JSX.Element
}

export default function Layout({children}: LayoutProps): JSX.Element {
    const {appState, updateAppStateDispatcher} = useContext(AppStateContext);

    return(
        <>
            <div style={{
                display: 'flex',
                flexDirection: 'row'
            }}>
                <div style={{
                    border: '1px solid black',
                    padding: '1rem'
                }}>
                    <ul>
                        <li>
                            <Link href="/">Home</Link>
                        </li>
                        <li>
                            <Link href="/add">Add</Link>
                        </li>
                        <li>
                            <Link href="/subtract">Subtract</Link>
                        </li>
                        <li>
                            <Link href="/multiply">Multiply</Link>
                        </li>
                        <li>
                            <Link href="/divide">Divide</Link>
                        </li>
                        <li>
                            <Link href="/reset">Reset</Link>
                        </li>
                    </ul>
                </div>
                <div style={{
                    border: '1px solid black',
                    padding: '1rem'
                }}>
                    Current Count: {appState.currentCount}<br />
                    Current Modifier: <input type="number" min="0" max="20" value={appState.currentModifier} onChange={(e) => {
                        updateAppStateDispatcher({type: 'setModifier', payload: parseInt(e.target.value)})
                    }} /><br/>
                    {children}
                </div>
            </div>            
        </>
    )
}
Again we are importing the AppStateContext and then extracting the current AppState and this time an appStateDispatcher to communicate with the reducer that we created earlier. We dispatch the setModifier type with the payload of the current value any time the currentModifer changes, then we have links to the operations that we want to do. Again, we just dispatching a request to modify the state this time instead of actually modifying the state like we did last time. This does allow us to have further separation of concerns. In the previous example the button was responsible for knowing that a change to the state was necessary and how to do it. This time the button is responsible for knowing that a change to the state is necessary, but does not perform it. Rather it requests the dispatcher to do the modification that is defined in the reducer. This makes it so that any logic changes can be done in the reducer, and any thing about where the button is displayed has nothing to do with the logic of modifying the application state.

Modify the src/pages/_app.tsx file as follows:

// import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { AppStateContextProvider } from '@/context/AppContext'
import Layout from '@/components/Layout'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <AppStateContextProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
    </AppStateContextProvider>
  )
}
This is actually the exact same code as last time. Again we have our AppState provider that is providing the state to the layout and default component, which again in the context of Next.js is a page and therefore all pages and all of their components will have access to the application state as well.

Update the src/pages/index.tsx file as follows:

import Head from 'next/head'

export default function Home() {
  return (
    <>
      <Head>
        <title>Managing AppState with useReducer</title>
        <meta name="description" content="Demonstrates managing entire app state with nextjs using react's built in useReducer method" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <header>
        <h1>Home</h1>
      </header>
      <main>
        <p>Select an action on the left to modify the current count. You can update the modifier by using the spinner next to the current modifier on this page or any other page.</p>
        <p>In this demonstration we are managing the entire app state using the useReducer method that comes with React.</p>
      </main>
    </>
  )
}
At this point you can run the following command to pull open the site:
npm run dev
Once again, all that will work at this point is using the spinner to increase the modifier.

Create a src/pages/add.tsx file with the following code.

import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function AddPage() {
    const { appState, updateAppStateDispatcher } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Add</title>
            </Head>
            <header>
                <h1>Add</h1>
            </header>
            <main>
                <button onClick={() => {
                    updateAppStateDispatcher({type:'add'})
                }}>Add {appState.currentModifier} to the count</button>
            </main>
        </>
    )
}
Once again we are pulling in the context and extracting out the appState and the updateAppSateDispatcher from the context. The page has a button that says “Add 1 to the count” where 1 is the current value of currentModifier. Changing the modifier in the spinner changes the text on this button. Pressing the button execute the stateDispatcher for the type of add, and executes the appropriate logic within the reducer that we defined in our context file. As stated again, this adds to a separation of concerns for UI and application state.

As in the previous example it is pretty much wash rinse and repeat from here only you will be changing some text on the page and the type of value for the updateAppStateDispatcher

src/pages/subtract.tsx

import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function SubtractPage() {
    const { appState, updateAppStateDispatcher } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Subtract</title>
            </Head>
            <header>
                <h1>Subtract</h1>
            </header>
            <main>
                <button onClick={() => {
                    updateAppStateDispatcher({type:'subtract'})
                }}>Subtract {appState.currentModifier} from the count</button>
            </main>
        </>
    )
}
src/pages/multiply.tsx
import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function MultiplyPage() {
    const { appState, updateAppStateDispatcher } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Multiply</title>
            </Head>
            <header>
                <h1>Multiply</h1>
            </header>
            <main>
                <button onClick={() => {
                    updateAppStateDispatcher({type:'multiply'})
                }}>Multiply the current count by {appState.currentModifier}</button>
            </main>
        </>
    )
}
src/pages/divide.tsx
import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function DividePage() {
    const { appState, updateAppStateDispatcher } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Divide</title>
            </Head>
            <header>
                <h1>Divide</h1>
            </header>
            <main>
                <button onClick={() => {
                    updateAppStateDispatcher({type:'divide'})
                }}>Divide the current count by {appState.currentModifier}</button>
            </main>
        </>
    )
}
src/pages/reset.tsx
import Head from 'next/head'
import { AppStateContext } from "@/context/AppContext";
import { useContext } from "react";

export default function ResetPage() {
    const { appState, updateAppStateDispatcher } = useContext(AppStateContext);

    return (
        <>
            <Head>
                <title>Reset</title>
            </Head>
            <header>
                <h1>Reset</h1>
            </header>
            <main>
                <button onClick={() => {
                    updateAppStateDispatcher({type:'reset'})
                }}>Reset the current count to 0 and the current modifier to 1</button>
            </main>
        </>
    )
}
Test out the application using state in this manner.

As stated before this is another interesting way of managing state across the entire Next.js application. The advantage here is that the logic has been extracted to the reducer instead of needing to be determined by a setState method. Where it could get hairy is back in the AppContext.tsx file at line 13 where we are defining the IAction interface and basically specifying all of the different actions and payloads that action could receive. This interface could get really big and stop to be an advantage and become a hinderance. For example what if we wanted one reducer for the currentCount method, and then another reducer for the currentModifier portion of the state because right now we sort of have a mixed bag. Well, that’s where redux toolkit (rtk) comes in and is the option that we will be discussing next.

If you are following along go ahead and commit these files to your useReducer branch, then swap back out to main and create a new branch based on main called rtk.

Redux Toolkit (RTK)

Managing Next.js application state with Redux Toolkit (RTK)

In this case we will be bringing in a popular third party library called Redux Toolkit. Anyone who has done enough React programming has probably come across this before, and since Next is just a glorified version of React it should come as no surprise that we will be discussing redux for state management with Next.js.

To begin, execute the following commands to install the reduxtoolkit and react-redux

npm install @reduxjs/toolkit react-redux
Redux has its own provider / context so we will not be creating them this time. Instead, we will be creating what is known as a “store” which is basically an in memory json object to store the application state. We will then createSlices of reducers to the store to make up the reducer that the react toolkit uses and dispatch things that way.

This time I am going to take the approach of breaking things up in to “features” that usually has the component view of the state, the manager of the state as a slice and any other files that can assist with the feature in a single folder, then we will provide those slices to the store as you will soon see.

In this instance the “counter” is one feature, and the “modifier” is another feature.

In the “src” folder create a new folder called “features”. In the features folder create a new folder called “counter” in the “counter” folder create a new file called “counterSlice.ts”. Notice that this is not a tsx file because in this file we are not export a react element.

Give it the following code.

import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit/dist/createAction";

export interface CounterState {
    value: number
}

const initialState: CounterState = {
    value: 0
}

export const counterSlice = createSlice({
    name: 'counter',
    initialState,
    reducers: {
        add: (state, action: PayloadAction<number>) => {
            state.value += action.payload
        }
    }
})
We are going to add additional reducers later, but let’s stop here and discuss what is going on.

We are imporing the createSlect from the redux toolkit as well as the PayloadAction type.

We are creating an interface that represents this slice of the state, then create a default value that matches the interface.

We are then using the createSlect function to create a slice of state called counter, giving it the initial state, and then specifying reducers. This is similar to the switch statement we did with the useReducer earlier except this time we are actually mutating the state instead of setting it in an unmutable way. This is because RTK has some things that detect state changes and returns them in an unmutable way. Later we will be able to extract each of these reducers as an action, and then export the main reducer that will be used by our components.

Update the code for your counterSlice.ts to the following:

import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit/dist/createAction";

export interface CounterState {
    value: number
}

const initialState: CounterState = {
    value: 0
}

export const counterSlice = createSlice({
    name: 'counter',
    initialState,
    reducers: {
        add: (state, action: PayloadAction<number>) => {
            state.value += action.payload
        },
        subtract: (state, action: PayloadAction<number>) => {
            state.value -= action.payload
        },
        multiply: (state, action: PayloadAction<number>) => {
            state.value *= action.payload
        },
        divide: (state, action: PayloadAction<number>) => {
            state.value /= action.payload
        },
        reset: (state) => {
            state.value = initialState.value
        }
    }
})

export const {add, subtract, multiply, divide, reset} = counterSlice.actions

export default counterSlice.reducer
Take not as well that we did not consider the modifier this time. Instead, we are providing a number in the payload. We talked about separation of concerns earlier where we separated the UI from the state management. Well with slices we are also separating each piece of state to its own concern as well.

To the features folder let’s create a new folder called “modifier”. In the modifier folder create a new file called “modifierSlice.ts”. Give it the following code.

import { createSlice } from "@reduxjs/toolkit"
import type { PayloadAction } from "@reduxjs/toolkit"

export interface ModifierState {
    value: number
}

const initialState: ModifierState = {
    value: 1
}

export const modifierSlice = createSlice({
    name: 'modifier',
    initialState,
    reducers: {
        setValue: (state, action: PayloadAction<number>) => {
            state.value = action.payload
        }
    }
});

export const { setValue } = modifierSlice.actions

export default modifierSlice.reducer;
Once again, we are just doing one reducer that sets the value. As you might expect, we are going to use the spinner again.

We will get into the UI portion of this later, but for now let’s work on bringing these “slices” in to one reducer / store.

In the src folder create a new folder called “store”. To the “store” folder create a new file called “index.tsx”. Give it the following code:

import { configureStore } from "@reduxjs/toolkit"
import counterReducer from '../features/counter/counterSlice'
import modifierReducer from '../features/modifier/modifierSlice'

export const store = configureStore({
    reducer: {
        counter: counterReducer,
        modifier: modifierReducer,
    },
});

// Infer the RootState and AppDispatch types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Infer the type {counter: value:0, modifier: value: 1}
export type AppDispatch = typeof store.dispatch
Now that we have built our single store with our reducers the inferred stat is now just a little different.

In the other two examples our application state was:

{
    "currentCount": 0,
    "currentModifier": 1
}
In this example our application state is:
{
    "counter": {
        "value": 0
    },
    "modifier": {
        "value": 1
    }
}
If we wanted to change the initial keys we would change the ‘name’ property in either of the slice files. If we wanted to change the property names within the slice, then we would change the interface definition that is using the default value.

Before we get in to modifying the UI of the application let’s discuss something else. As you recall when we reset the state we were simply using a reducer or setState method that set both the currentCount and the currentModifier values at the same time. Well now we have a complication on our hands because in the Redux Toolkit world any slice of state is not aware of the other slice’s state. Instead it will have to be selected and provided as a payload to another slice. We will demonstrate some of that in the UI but before I do let’s show how we can reset but the counter and the modifier at the same time despite the fact that we are using slices.

Return to the modifierSlice.ts file.

To the top add the following import.

import {reset} from "@/features/counter/counterSlice"
So that we can bring in the reset action from the counterSlice

Next, we will add an extraReducers property to our createSlect function with the following code:

extraReducers: {
        [reset.type]: (
            state
        ) => {
            // When the counter resets then the modifier resets as well
            state.value = initialState.value
        }
    }
This will make it such that when the counterReducer receives a reset command, then the modifierReducer will also receive that command and execute the logic that we entered in to there.

Your entire modifierSlice.ts file should look like this:

import { createSlice } from "@reduxjs/toolkit"
import type { PayloadAction } from "@reduxjs/toolkit"
import {reset} from "@/features/counter/counterSlice"

export interface ModifierState {
    value: number
}

const initialState: ModifierState = {
    value: 1
}

export const modifierSlice = createSlice({
    name: 'modifier',
    initialState,
    reducers: {
        setValue: (state, action: PayloadAction<number>) => {
            state.value = action.payload
        }
    },
    extraReducers: {
        [reset.type]: (
            state
        ) => {
            // When the counter resets then the modifier resets as well
            state.value = initialState.value
        }
    }
});

export const { setValue } = modifierSlice.actions

export default modifierSlice.reducer;
Since we organized our code in to features let’s create a component for our counter and modifier components. Let’s start with creating one for the modifier.

To the src/features/modifier folder create a new file called “Modifier.tsx”. Give it the following code.

import React from 'react'
import type { RootState } from '@/store'
import { useSelector, useDispatch } from 'react-redux'
import { setValue } from './modifierSlice'

export function Modifier() {
    const modifierValue = useSelector((state: RootState) => state.modifier.value)
    const dispatch = useDispatch();

    return (
        <>
            Modifier <input type="number" min="1" max="20" value={modifierValue} onChange={(e) => {
                dispatch(setValue(parseInt(e.target.value)))
            }} />
        </>
    )
}
What we are doing here is importing the store and our slice. Within our component we are selecting some values out of our root state / store which will be used to display the current value of that state. We also imported react-redux’s useDispath. We then use that dispatch to call the setValue action from the modifier slice and set the value using the parsedInteger from the number input. This will ensure that the modifier value will be set using the logic from the reducer we created earlier.

To the src/features/counter folder let’s create a new file called “Counter.tsx” which will be used as the primary display component for our counter and its current value.

import React from "react"
import type {RootState} from "@/store"
import { useSelector } from 'react-redux'

export function Counter() {
    const count = useSelector((state: RootState) => state.counter.value)
    
    return(
        <>
            Current Count: {count}
        </>
    )
}
This one is a little bit more simple because we are only selecting the value and displaying it. We are not dispatching it on this screen.

Since we broke this up in to feature’s let’s add the add, subtract, multiply, divide, and reset buttons to the feature, and we will import them on to the pages later.

In src/features/counter create a new file called AddButton.tsx and give it the following code:

import React from "react"
import type { RootState } from "@/store"
import { useSelector, useDispatch } from 'react-redux'
import { add } from './counterSlice'

export function AddButton() {
    const currentModifierValue = useSelector((state: RootState) => state.modifier.value)
    const dispatch = useDispatch();

    return (
        <button onClick={() => dispatch(add(currentModifierValue))}>
            Add {currentModifierValue} to the current count
        </button>
    )
}
In this case we are importing the add action from the counterSlice, but retrieving currentModifierValue (and not the current value of the current count slice) because we gained access to the RootState, and selected the value that we needed from the root state. Then when the button is clicked we dispatch the add action with the modifiervalue that we retrieved from the state selector. Let’s go ahead and add the subtract, multiply, and divide buttons.

SubtractButton.tsx

import React from "react"
import type { RootState } from "@/store"
import { useSelector, useDispatch } from 'react-redux'
import { subtract } from './counterSlice'

export function SubtractButton() {
    const currentModifierValue = useSelector((state: RootState) => state.modifier.value)
    const dispatch = useDispatch();

    return (
        <button onClick={() => dispatch(subtract(currentModifierValue))}>
            Subtract {currentModifierValue} from the current count
        </button>
    )
}
MultiplyButton.tsx
import React from "react"
import type { RootState } from "@/store"
import { useSelector, useDispatch } from 'react-redux'
import { multiply } from './counterSlice'

export function MultiplyButton() {
    const currentModifierValue = useSelector((state: RootState) => state.modifier.value)
    const dispatch = useDispatch();

    return (
        <button onClick={() => dispatch(multiply(currentModifierValue))}>
            Multiply the current count by {currentModifierValue}
        </button>
    )
}
DivideButton.tsx
import React from "react"
import type { RootState } from "@/store"
import { useSelector, useDispatch } from 'react-redux'
import { divide } from './counterSlice'

export function DivideButton() {
    const currentModifierValue = useSelector((state: RootState) => state.modifier.value)
    const dispatch = useDispatch();

    return (
        <button onClick={() => dispatch(divide(currentModifierValue))}>
            Divide the current count by {currentModifierValue}
        </button>
    )
}
ResetButton.tsx
import React from "react"
import type { RootState } from "@/store"
import { useSelector, useDispatch } from 'react-redux'
import { reset } from './counterSlice'

export function ResetButton() {
    const currentModifierValue = useSelector((state: RootState) => state.modifier.value)
    const dispatch = useDispatch();

    return (
        <button onClick={() => dispatch(reset())}>
            Reset the current count to 0 and the modifier to 1
        </button>
    )
}
Next let’s go ahead and create a standard layout for the application and provide the store to the components.

Create a new folder called “components”, and a new file called “Layout.tsx” to that folder. Give it the following code:

import Link from "next/link"
import {Counter} from "@/features/counter/Counter"
import { Modifier } from "@/features/modifier/Modifier"

interface LayoutProps {
    children: JSX.Element
}

export default function Layout({children}: LayoutProps): JSX.Element {
    return(
        <>
            <div style={{
                display: 'flex',
                flexDirection: 'row'
            }}>
                <div style={{
                    border: '1px solid black',
                    padding: '1rem'
                }}>
                    <ul>
                        <li>
                            <Link href="/">Home</Link>
                        </li>
                        <li>
                            <Link href="/add">Add</Link>
                        </li>
                        <li>
                            <Link href="/subtract">Subtract</Link>
                        </li>
                        <li>
                            <Link href="/multiply">Multiply</Link>
                        </li>
                        <li>
                            <Link href="/divide">Divide</Link>
                        </li>
                        <li>
                            <Link href="/reset">Reset</Link>
                        </li>
                    </ul>
                </div>
                <div style={{
                    border: '1px solid black',
                    padding: '1rem'
                }}>
                    <Counter /><br />
                    <Modifier />
                    {children}
                </div>
            </div>            
        </>
    )
}
The links on the side are standard but what is different here is that we pulled in the Counter and Modifier as their own components instead of displaying their state directly, we are importing their components for display.

Now let’s provide the store and layout for every page in our application.

Modify the src/pages/_app.tsx file as follows.

// import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import {store} from '@/store'
import { Provider } from 'react-redux'
import Layout from '@/components/Layout'

export default function App({ Component, pageProps }: AppProps) {  
  return (
    <Provider store={store}>
      <Layout>
        <Component {...pageProps} />
      </Layout>      
    </Provider>    
  )
}
The big difference here between the other two methods is that we are using a provider from react-redux instead of a provider that we created within our own context. The distinction is important but it is essentially the same, except we are relying on a 3rd party for that abstraction. However it is the same as any other provider. This is what allows those useSelector and useDispatchers work. If those components were not downstream of this provider, then the useSelector and useDispatch would not work. Just like our useContext would not have worked in the reducers and useState providers that we made ourselves in the other examples.

Modify the src/pages/index.tsx file as follows:

import Head from 'next/head'

export default function Home() {
  return (
    <>
      <Head>
        <title>Managing AppState with Redux Toolkit (RTK)</title>
        <meta name="description" content="Demonstrates managing entire app state with nextjs using Redux Toolkit (RTK)" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <header>
        <h1>Home</h1>
      </header>
      <main>
        <p>Select an action on the left to modify the current count. You can update the modifier by using the spinner next to the current modifier on this page or any other page.</p>
        <p>In this demonstration we are managing the entire app state using the Redux Toolkit (RTK).</p>
      </main>
    </>
  )
}
You can run the npm run dev command again here to start the server up and see this in action. Just like the other two examples. At this point all you can do is change the modifier value. Let’s add the operation pages.

To the src/pages/Add.tsx file:

import Head from 'next/head'
import {AddButton as ActionButton} from '@/features/counter/AddButton'

const pageName = "Add"

export default function AddPage() {
    return (
        <>
            <Head>
                <title>{pageName}</title>
            </Head>
            <header>
                <h1>{pageName}</h1>
            </header>
            <main>
                <ActionButton></ActionButton>                
            </main>
        </>
    )
}
This time I am importing the AddButton as ActionButton from the features that we made earlier. I am setting a constant for the page name and setting that in the appropriate places. This way there is less to modify when we move along to the other pages. What is neat about this is instead of any logic being on the page, the page is truly just concerned with displaying the information. The logic was built in to the slice, and the features said when to call those slice functions.

Subtract.tsx

import Head from 'next/head'
import {SubtractButton as ActionButton} from '@/features/counter/SubtractButton'

const pageName = "Subtract"

export default function SubtractPage() {
    return (
        <>
            <Head>
                <title>{pageName}</title>
            </Head>
            <header>
                <h1>{pageName}</h1>
            </header>
            <main>
                <ActionButton></ActionButton>                
            </main>
        </>
    )
}
Multiply.tsx
import Head from 'next/head'
import {MultiplyButton as ActionButton} from '@/features/counter/MultiplyButton'

const pageName = "Multiply"

export default function MultiplyPage() {
    return (
        <>
            <Head>
                <title>{pageName}</title>
            </Head>
            <header>
                <h1>{pageName}</h1>
            </header>
            <main>
                <ActionButton></ActionButton>                
            </main>
        </>
    )
}
Divide.tsx
import Head from 'next/head'
import {DivideButton as ActionButton} from '@/features/counter/DivideButton'

const pageName = "Divide"

export default function DividePage() {
    return (
        <>
            <Head>
                <title>{pageName}</title>
            </Head>
            <header>
                <h1>{pageName}</h1>
            </header>
            <main>
                <ActionButton></ActionButton>                
            </main>
        </>
    )
}
Reset.tsx
import Head from 'next/head'
import {ResetButton as ActionButton} from '@/features/counter/ResetButton'

const pageName = "Reset"

export default function ResetPage() {
    return (
        <>
            <Head>
                <title>{pageName}</title>
            </Head>
            <header>
                <h1>{pageName}</h1>
            </header>
            <main>
                <ActionButton></ActionButton>                
            </main>
        </>
    )
}
Test out the application using this method for a while and see how it goes.

If you have been following, along go ahead and commit these changes to your rtk branch.

Conclusion

Managing the state of your entire application is a very important and sensitive subject. Some things that we did not discuss earlier is just how much detail is needed for the global state and how much is needed just for the page? The page can still use its own useState or useReducer methods as necessary and can be used to augment the rest of the application state if that is indeed your intention. Be sure to plan out what is necessary for every page, and what is only necessary for the current page. For example, the layout or theme that a user selected is probably good to keep in the entire application state, as well as some of the login information if your application is having users log in to it. But data that will go on individual forms like contact us forms, or content management forms are probably not good ideas to keep as entire application state.

Going through this you might conclude that using redux toolkit is probably the way to go. And you would be right if you do indeed believe that your application state could get big and unwieldy. But remember we were able to just use react’s useState function for this same sample application. Even React Toolkit’s own documentation states that you should not use Redux Toolkit just because some one else said that you need to do so. There are tradeoffs. For example the first example is clear how the application state changes, when a button is pressed, and that might be more legible code for other developers that join your team. The second example shows how state can change when a certain action has been dispatched, but reading just that file alone might have you wondering when does that happen? On the other hand the trade off is that you will only have one spot to define the logic. The third example has allot of abstraction, and if you only have a small bit of state, this can be overkill. Consider what it took for us to be able to reset both the modifier and counter at the same time. Consider too just how many more files we created compared to the other examples as well. Organizing the code with features in such a way can make it easier to see how actions trigger reducers, but if you have your reducers being triggered outside of the feature folder, or you have many files in your feature folder it still could be difficult to understand for developers that are new to your team.

There is no one size fits all for state management, but I hope that experiencing these three different ways can help you consider your use case and guide you to which strategy to use. Happy coding.

About Intertech

Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Intertech teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Intertech employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Intertech is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.