Initialising the value of a useState based on useRouter with NextJS SSR
The Context
A colleague asked me for help figuring out why
query
from useRouter
was undefined
on a NextJS (12) project using SSR.The Problem
We had a dashboard page which has multiple tabs on the same page. We wanted to allow a user to share a link with another user which would open the correct tab for them when they opened the page.
NextJS 13 does this by default with it’s Layouts (nested navigation using the
/app
directory), but we were using NextJS 12!Our approach was to use a query string (e.g.
?tab=3
) to access the tab that the user intended to open the page to. We were using the built-in hook useRouter from NextJS to grab the query.We needed a
useState
as when the user taps the tab to transition to another tab, we will need to update the value with the state setter.import { useRouter } from "next/router"; /* Inside FC */ const { query } = useRouter(); const [params] = useState(query); /* ... */ <h1> Query params (useRouter initialises useState): {JSON.stringify(params)} </h1>
The problem was, the
query
parameter always returned undefined
:Debugging
The first thing we did was attach VS Code’s debugger to the node process which was running the NextJS server.
In the debugging tab in VS Code, we could see that the query variable was
undefined
, however using console.log
we could see in the browser console that the value of query
was an object with the query string as expected.We used the
query
variable directly from useRouter
without passing it through a useState
and we got the following:<h1> Query params (useRouter): {JSON.stringify(query)} </h1> <h1> Query params (useRouter initialises useState): {JSON.stringify(params)} </h1>
After a quick google, we found similar issues.
Why does this happen?
NextJS is a server-side rendering framework for React. This means there are two processes running - one on the server (node), which pre-renders the page before sending it to the browser which then takes over (V8 on Chrome).
This means the first render happens on the server and the second then happens when the client “hydrates” the page and client-side JS takes over control of the page and user interactions. One advantage of this provides is quicker load times, as API calls to get data required to render the page happen on the server and are sent in the initial response to the client.
However,
useRouter
does not return the query string when rendering on the server.When we use a
useState
, the state is initialised to undefined
(what it was on the server) and when the second render happens on the client, useState
returns the same state from the previous render - remember this hook are used to persist state between renders.Why does useRouter
not return the query string on SSR? (hint: it’s not using SSR!)
The default rendering method of NextJS <12 is documented in the Dynamic Routes documentation which also notes the caveats, saying:
Pages that are statically optimized by Automatic Static Optimization  will be hydrated without their route parameters provided, i.eÂquery
 will be an empty object ({}
).
Automatic Static Optimisation means that NextJS detects the absence of
getServerSideProps
 and getInitialProps
in your page and determines that this can be a statically (pre-)generated page (i.e. delivered by a CDN as a static file without the need to run any server process). This is also known as SSG (Static Site Generation).IfÂgetServerSideProps
 orÂgetInitialProps
 is present in a page, Next.js will switch to render the page on-demand, per-request (meaning Server-Side Rendering).
The Solution
The above section means that by simply adding a
getInitialProps
function to the page, we can force the rendering method back to SSR, which requires a server-side node process running, but also will have access to the query string (as the page is rendered at request-time, not build-time).export const getInitialProps = async (ctx) => { return { props: {} } }
However, we might not want to dedicate a process to doing SSR if we can avoid it for this use-case!
The other option is to have a
useEffect
which runs on the client once useRouter
is ready:const router = useRouter(); const [selectedTab, setSelectedTab] = useState(); useEffect(() => { if(router.isReady) setSelectedTab(router.query.tab); }, [router.isReady]);
I find this slightly ugly - I really try to avoid using
useEffect
as having side-effects triggered by a change in the dependency array can get really tricky → especially if you have multiple useEffect
s on a single page performing operations on the same data! ⚠️One idea to prevent this pattern from permeating into the codebase would be to create a custom hook which hides the ugliness away!
type QueryString = string | string[] | undefined; const useStateFromQueryString = ( stateSelector: (query: ParsedUrlQuery) => QueryString ): [QueryString, Dispatch<SetStateAction<QueryString>>] => { const router = useRouter(); const [state, setState] = useState<QueryString>(); useEffect(() => { if (router.isReady) setState(stateSelector(router.query)); }, [router.isReady, router.query, stateSelector]); return [state, setState]; };
The Code
From
create-next-app
, I created a minimal example to demonstrate:import Head from "next/head"; import { Inter } from "next/font/google"; import { useRouter } from "next/router"; import { Dispatch, SetStateAction, useEffect, useState } from "react"; import { ParsedUrlQuery } from "querystring"; const inter = Inter({ subsets: ["latin"] }); const format = (json: ParsedUrlQuery) => JSON.stringify(json, null, 4); type QueryString = string | string[] | undefined; const useStateFromQueryString = ( stateSelector: (query: ParsedUrlQuery) => QueryString ): [QueryString, Dispatch<SetStateAction<QueryString>>] => { const router = useRouter(); const [state, setState] = useState<QueryString>(); useEffect(() => { if (router.isReady) setState(stateSelector(router.query)); }, [router.isReady, router.query, stateSelector]); return [state, setState]; }; export default function Home() { const { query } = useRouter(); // useState here doesn't work as page uses SSG which is due to the // absence of getServerSideProps or getInitialProps on the page const [params] = useState(query); // useStateFromQueryString works as we wait until router.isReady on the client const [customHookState] = useStateFromQueryString(({ tab }) => tab); return ( <> <Head> <title>Create Next App</title> <meta name="description" content="Generated by create next app" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="/favicon.ico" /> </Head> <main> <h1 className={inter.className} style={{ margin: 50 }}> Query params (useRouter): {format(query)} </h1> <h1 className={inter.className} style={{ margin: 50 }}> Query params (useRouter initialises useState): {format(params)} </h1> <h1 className={inter.className} style={{ margin: 50 }}> customHookState: {customHookState} </h1> </main> </> ); } /* * No need to export getInitialProps with this solution * however this would also fix the issue by switching to SSR! export const getInitialProps = async (ctx) => { return { props: {} } } */
The result: