Sharing state between Next.js page navigations using React Contexts
- 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.