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:
notion image

Debugging

The first thing we did was attach VS Code’s debugger to the node process which was running the NextJS server.
notion image
notion image
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>
notion image
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 useEffects 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:
notion image