Sharing state between Next.js page navigations using React Contexts

Versions used:
  • react@17.0.1

While developing Next.js applications, a common requirement is to keep state alive between page navigations. You might want to keep global state to keep track of a user's preferences, an authentication token, or exposing side-effects as state, think offline mode.

Let's start by implementing a simple example, starting with tracking state on the Next.js page component, then migrating the state to a custom App and finally exposing a global React Context.

Keeping track of the state

Imagine we have a page that keeps track of the amount of times the user clicked a button. We use React's useState hook to declare a new state variable and define a function to increment the click amount and bind it to the button's onClick handler.

// pages/index.js

import { useState } from 'react';

const IndexPage = () => {
  const [clickAmount, setClickAmount] = useState(0);
  const increment = () => setClickAmount((amount) => amount + 1);

  return (
    <>
      You clicked the button <strong>{clickAmount}</strong> times.
      <button onClick={increment}>
        Click me!
      </button>
    </>
  );
};

export default IndexPage;

Every time you click the button the state will increment by 1 and the page will re-render the view with the new clickAmount state. However, if you navigate to a different page, the IndexPage will unmount and throw away its state.

To keep this state in memory between navigations, instead of keeping the state on the page itself, we need to use Next.js' App component.

Implementing a custom App component

Next.js uses an <App /> component to handle page initializations. This component will be kept alive during the entire application's lifecycle. Overriding this component with a custom implementation, allows us to declare state in the application that will survive page navigations.

Next.js <App /> component will keep state alive during client side transitions. If you refresh the page, or link to another page without utilizing Next.js <Link />, the <App /> will initialize again and the state will be reset.

The <CustomApp /> Component will receive the current page as Component and it's properties as pageProps. Now we can move the state logic from our IndexPage into the component and pass the state and handler to every page, not just the IndexPage.

// pages/_app.js

import { useState } from 'react';

const CustomApp = ({ Component, pageProps }) => {
  const [clickAmount, setClickAmount] = useState(0);
  const increment = () => setClickAmount((amount) => amount + 1);

  return (
    <Component
      {...pageProps}
      clickAmount={clickAmount}
      increment={increment}
    />
  );
};

We'll have to update the IndexPage, as it's no longer keeping track of states but receiving it through its props, thanks to our <CustomApp /> component.

// pages/index.js

const IndexPage = ({ clickAmount, increment }) => (
  <>
    You clicked the button <strong>{clickAmount}</strong> times.
    <button onClick={increment}>
      Click me!
    </button>
  </>
);

export default IndexPage;

Our <CustomApp /> is now wrapping and rendering all the pages, and Next.js will keep the state alive while mounting and unmounting different pages.

Using a React Context in the custom App

React Contexts are a great feature for sharing global state. If the state is common enough to be used in numerous components, it can be tiresome and error prone to keep passing props multiple levels deep. This is especially true if some of these components only pass the props through to their children, without using or mutating the props.

Contexts allow us to reach these global states without having to worry about passing props. Let's create a new context and provider for our clickAmount state and our increment handler.

// contexts/click.js

import { createContext, useState } from 'react';

const ClickContext = createContext(0, () => {});

export const ClickProvider = ({ children }) => {
  const [clickAmount, setClickAmount] = useState(0);
  const increment = () => setClickAmount((amount) => amount + 1);

  return (
    <ClickContext.Provider value={[clickAmount, increment]}>
      {children}
    </ClickContext.Provider>
  );
};

export default ClickContext;

Now let's update our CustomApp to use the new ClickProvider component.

// pages/_app.js

import { ClickProvider } from '../contexts/click';

const CustomApp = ({ Component, pageProps }) => (
  <ClickProvider>
    <Component {...pageProps} />
  </ClickProvider>
);

export default CustomApp;

Finally, we have to update our IndexPage, as its no longer receiving the state and handler through it's props, but through the ClickContext.

// pages/index.js

import { useContext } from 'react';
import ClickContext from '../contexts/click';

const IndexPage = () => {
  const [clickAmount, increment] = useContext(ClickContext);

  return (
    <>
      You clicked the button <strong>{clickAmount}</strong> times.
      <button onClick={increment}>
        Click me!
      </button>
    </>
  );
};

export default IndexPage;

By wrapping all the pages with our ClickProvider in our <CustomApp />, every page and / or component can now access the exposed state simply by making use of React's useContext hook with the ClickContext as argument, no matter how deep they are in the tree.

This makes global state neatly structured, while staying easily accessible.

Stephan Lagerwaard
Stephan Lagerwaard
Frontend Engineer at Fiberplane.