Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server client html mismatch occurs on first render #290

Open
doxylee opened this issue Mar 7, 2022 · 23 comments
Open

Server client html mismatch occurs on first render #290

doxylee opened this issue Mar 7, 2022 · 23 comments
Labels
adapters/next/pages Uses the Next.js pages router

Comments

@doxylee
Copy link

doxylee commented Mar 7, 2022

Warnings like Warning: Expected server HTML to contain a matching <button> in <div>. occurs because html was rendered with no url params on server side, but rendered with url params in client side.

I think Next.js handles this problem by setting router.query to an empty object on the first render, and sets client url params on the next render. But because useUrlState uses window.location.search and not router, this warning occurs.

This can be avoided by using code like this:

const [queryState, setQueryState] = useQueryState(...);
const [state, setState] = useState();
useEffect(() => setState(queryState), [queryState]);

But it would be better if this boilerplate could be avoided in some way.

@doxylee
Copy link
Author

doxylee commented Mar 7, 2022

There are some other drawbacks on the above code, like state being one frame(?) late compared to queryState.
Maybe this would be better?

const [queryState, setQueryState] = useQueryState(...);
const isFirstRender = useRef(true);
useEffect(() => isFirstRender.current = false, []);
const state = isFirstRender.current ? defaultValue : queryState;

@minardimedia
Copy link

Any way to handle this when you have multiple queries?

@Distortedlogic
Copy link

Is this fixed for server side rendering yet? I would love to use this package instead of getting query params with getServerSideProps

@franky47
Copy link
Member

franky47 commented Sep 5, 2022

Unfortunately the Next.js router does not expose querystring params in the render tree in SSR, so those should be treated as client-side only (just like you would for code that depends on local storage values for example).

While theoretically it could be possible to get this kind of information on "per request" SSR (ie: page with getServerSideProps), other kinds of non-client renders (ISR, static optimisation at build time) are not attached to a request and therefore don't have querystring parameters.

@ViktorQvarfordt
Copy link

ViktorQvarfordt commented Oct 18, 2022

I think the correct solution would be to always return the default value on both server and client-side on the initial render. Reading from window.location should be done in an effect. See this article for an in-depth explanation of why this is important https://joshwcomeau.com/react/the-perils-of-rehydration/

@Katona
Copy link

Katona commented Jan 23, 2023

Isn't this a major issue? This means this boilerplate has to be used by everyone, or am I missing something?

@avisra
Copy link

avisra commented Jan 23, 2023

I'm running into this as well

@joelso
Copy link

joelso commented Mar 12, 2023

Got hit by this as well.

FWIW - we are using this wrapper hook as a workaround. It's based on @doxylee's comment above.
Haven't really battle tested it that much, but seems to do the job for the time being.

function useSsrSafeQueryState<T>(
    key: string,
    options: UseQueryStateOptions<T> & {
        defaultValue: T;
    }
) {
    const [queryState, setQueryState] = useQueryState<T>(key, options);

    const isFirstRender = useRef(true);
    useEffect(() => {
        isFirstRender.current = false;
    }, []);

    const state = isFirstRender.current ? options.defaultValue : queryState;

    return [state, setQueryState] as [T, typeof setQueryState];
}

☮️

@drichar
Copy link

drichar commented May 21, 2023

Expanding on @joelso's comment above, here I'm including all of useQueryState's overloads, from https://github.com/47ng/next-usequerystate/releases/tag/v1.7.2

import { useEffect, useRef } from 'react'
import {
  HistoryOptions,
  queryTypes,
  useQueryState as _useQueryState,
  UseQueryStateOptions,
  UseQueryStateReturn
} from 'next-usequerystate'

function useQueryState<T>(
  key: string,
  options: UseQueryStateOptions<T> & { defaultValue: T }
): UseQueryStateReturn<NonNullable<ReturnType<typeof options.parse>>, typeof options.defaultValue>

function useQueryState<T>(
  key: string,
  options: UseQueryStateOptions<T>
): UseQueryStateReturn<NonNullable<ReturnType<typeof options.parse>>, undefined>

function useQueryState(
  key: string,
  options: {
    history?: HistoryOptions
    defaultValue: string
  }
): UseQueryStateReturn<string, typeof options.defaultValue>

function useQueryState(
  key: string,
  options: Pick<UseQueryStateOptions<string>, 'history'>
): UseQueryStateReturn<string, undefined>

function useQueryState(key: string): UseQueryStateReturn<string, undefined>

function useQueryState<T = string>(
  key: string,
  options: Partial<UseQueryStateOptions<T>> & { defaultValue?: T } = {
    history: 'replace',
    parse: (x) => x as unknown as T,
    serialize: String,
    defaultValue: undefined
  }
) {
  const [queryState, setQueryState] = _useQueryState(key, options)

  const isFirstRender = useRef(true)
  useEffect(() => {
    isFirstRender.current = false
  }, [])

  const state = isFirstRender.current ? options.defaultValue : queryState

  return [state, setQueryState] as [T, typeof setQueryState]
}

export { useQueryState, queryTypes }

Then after a find and replace of

import { useQueryState, queryTypes } from 'next-usequerystate'

with

import { useQueryState, queryTypes } from '@/lib/useQueryState'

it works everywhere with SSR

@huksley
Copy link

huksley commented May 24, 2023

Nice @drichar
Also need many keys support, i.e. useQueryStates(key1, key2... etc)
so it can get and update multiple params in query string

@camin-mccluskey
Copy link

camin-mccluskey commented Sep 3, 2023

Ran into this problem today and found the solutions above fixed one issue but introduced another:

  • Using the library, as is, when the search params are present and we're refreshing (e.g. sharing a link) the hydration error would occur. i.e. link to page worked but refresh did not.
  • Using the "ssr safe" snippets above fixed the refresh issue but now first render from a link to the page did not work.

The only solution I could find was to wrap the whole component in a parent component which would conditionally render if router is ready. E.g.

const ProblemComponent = () => {
    const [productIds, setProductIds] = useQueryState(
    'productIds',
    queryTypes.array(queryTypes.string).withDefault([]),
  )
  ...
 return (
   <ProductList productIds={productIds} />
  )
}

export default function RouterSafeProblemComponent() {
  const isReady = useRouterReady()
  return isReady ? <ProblemComponent /> : null
}

Where the hook useRouterReady is defined as:

export const useRouterReady = () => {
  const [isReady, setIsReady] = useState(false)
  const router = useRouter()

  useEffect(() => {
    setIsReady(router.isReady)
  }, [router.isReady])

  return isReady
}

@MonstraG
Copy link

MonstraG commented Nov 9, 2023

import { useEffect, useState } from "react";
import { useQueryState, type UseQueryStateOptions } from "next-usequerystate";

export const useSsrSafeQueryState = <T,>(
	key: string,
	options: UseQueryStateOptions<T> & { defaultValue: T }
) => {
	const [queryState, setQueryState] = useQueryState<T>(key, options);

	const [isFirstRender, setIsFirstRender] = useState<boolean>(true);
	useEffect(() => {
		setIsFirstRender(false);
	}, []);

	const state = isFirstRender ? options.defaultValue : queryState;

	return [state, setQueryState] as [T, typeof setQueryState];
};

Had to switch from useRef to useState for isFirstRender, to make sure that if value is provided in query already, the change of state will properly re-render.

@DasOhmoff
Copy link

I have the same issue. Is there not going to be a fix for this?

@la289
Copy link

la289 commented Jan 11, 2024

same.

@franky47
Copy link
Member

Unfortunately the only way to solve this issue properly is the following suggested above by @camin-mccluskey:

Ran into this problem today and found the solutions above fixed one issue but introduced another:

  • Using the library, as is, when the search params are present and we're refreshing (e.g. sharing a link) the hydration error would occur. i.e. link to page worked but refresh did not.
  • Using the "ssr safe" snippets above fixed the refresh issue but now first render from a link to the page did not work.

The only solution I could find was to wrap the whole component in a parent component which would conditionally render if router is ready. E.g.

const ProblemComponent = () => {
    const [productIds, setProductIds] = useQueryState(
    'productIds',
    queryTypes.array(queryTypes.string).withDefault([]),
  )
  ...
 return (
   <ProductList productIds={productIds} />
  )
}

export default function RouterSafeProblemComponent() {
  const isReady = useRouterReady()
  return isReady ? <ProblemComponent /> : null
}

Where the hook useRouterReady is defined as:

export const useRouterReady = () => {
  const [isReady, setIsReady] = useState(false)
  const router = useRouter()

  useEffect(() => {
    setIsReady(router.isReady)
  }, [router.isReady])

  return isReady
}

Why is that ?

Note: this whole thread only concerns static pages in the pages router. The app router is not subject to those limitations, nor are pages in the pages router that have SSR enabled (via getServerSideProps).

First, you need to understand hydration, and how Next.js behaves differently on initial page load (or page reloads, same thing) vs on client-side navigation.

Page (re)load involves getting the HTML that was generated at build time. It was done so without any search params, so the default value (or null if none was provided) for all useQueryState hooks was used there.
When loading the page with a search param, React will perform the hydration render on top that statically built page, but end up with a rendered tree that contains the correct search param state, and throw the "Hydration mismatch" error.

On the other side, navigating client-side (using a <Link> or a router.push/replace call) does not fetch the HTML from the server, but only the JSON needed to render that page. There is no hydration step, and therefore no error.

Why can't we use the default value on the hydration render, then switch to the correct value ?

This causes two renders, one that uses an incorrect value (the default), to make hydration happy, then a second with the correct search param value. Flickering of UI being bad UX aside, the root of the hydration error is: the content generated at build time is incorrect for the current URL. Rendering twice allows that incorrect content to creep up in the application, only to be replaced.

The alternative of not rendering the parent component (which we'd call a client component in the app router, and wrap around a Suspense boundary) is both more correct and more efficient.

Why does SSR solve the problem ?

SSRd pages are never pre-rendered at build time, and are rendered per-request. Those requests do contain the search params, so nuqs can render the correct state and not cause an hydration error.

I want to keep a static page, what's the solution ?

Search params are runtime variables that can't be accessed at build time, much like the contents of cookies or localStorage, for which you would also need to fence off parts of the static render tree that depends on them.

Fortunately, the app router isn't affected by this issue, as it uses a different set of behaviours for initial page load and client-side navigation. But I can understand that upgrading is not a valid option for some.

@huksley
Copy link

huksley commented Jan 12, 2024

The only solution I could find was to wrap the whole component in a parent component which would conditionally render if router is ready. E.g.

Would it be possible to apply this logic at _app.tsx level?

@DasOhmoff
Copy link

The only solution I could find was to wrap the whole component in a parent component which would conditionally render if router is ready. E.g.

Would it be possible to apply this logic at _app.tsx level?

That is a good question, and also: would this create performance issues, like the app having to rerender everything multiple in different scenarios because the query params get changed while using the app?

@franky47
Copy link
Member

Would it be possible to apply this logic at _app.tsx level?

Technically, yes. It would essentially prevent any page from being pre-rendered at build time, and be rendered on the client only.

However you'd lose a lot of perks of using Next.js while doing so (SEO and First Contentful Paint being the most relevant). Just like the app router paradigm suggests moving client component boundaries as low in the tree as possible, this pattern of fencing off client-only code in the pages router should follow the same guidelines.

That is a good question, and also: would this create performance issues, like the app having to rerender everything multiple in different scenarios because the query params get changed while using the app?

No, the initial load would perform an empty hydration step on the empty shell generated at build time, then the second render would kick in and render the actual contents. Since the hook approach doesn't re-render subsequently, there should be no more re-renders on client side navigation.

@camin-mccluskey
Copy link

@franky47 I seem to be running into this issue, or something quite similar again. This time the snippet above (useRouterReady) is ineffectual.

Basically I can't navigate from a page where useQueryState is used. This appears to be the case when the page is statically generated but also when it's server side rendered. Wrapping the problematic component with the router ready logic makes no difference (except in the statically generated case where it solves the hydration error).

The precise issue an aborted fetch for the new route. If I hammer the link button I can eventually get through to the next route.

Screenshot 2024-02-09 at 14 56 47

Happy to create a new issue but it felt quite similar to this one.

@franky47
Copy link
Member

franky47 commented Feb 9, 2024

@camin-mccluskey that's very odd, yes please open a dedicated issue with the details, I'll try and reproduce it locally.

@camin-mccluskey
Copy link

Actually looks like I was able to "solve" it by increasing the throttleMs. Not sure that's a great solution - will open an issue anyway so you have a reference

@MonstraG
Copy link

MonstraG commented Feb 9, 2024

I had this happen before, in my case I had bad useEffect which continuously tried to update the state, which lead to constant navigation to new query, which led to being unable to navigate away.

Easiest way to check is to enable "Highlight updates when components render." in react devtools extension:
image
and see if the page is continiusly flashing.

@camin-mccluskey
Copy link

I had this happen before, in my case I had bad useEffect which continuously tried to update the state, which lead to constant navigation to new query, which led to being unable to navigate away.

Easiest way to check is to enable "Highlight updates when components render." in react devtools extension: image and see if the page is continiusly flashing.

You're absolutely right - this is exactly what was happening

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
adapters/next/pages Uses the Next.js pages router
Projects
None yet
Development

No branches or pull requests