47ng / nuqs

Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query string.
https://nuqs.47ng.com
MIT License
4.88k stars 104 forks source link

Server client html mismatch occurs on first render #290

Open doxylee opened 2 years ago

doxylee commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

Any way to handle this when you have multiple queries?

Distortedlogic commented 2 years ago

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

franky47 commented 2 years ago

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 commented 2 years ago

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 commented 1 year ago

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

avisra commented 1 year ago

I'm running into this as well

joelso commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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

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 commented 1 year ago
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 commented 10 months ago

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

la289 commented 10 months ago

same.

franky47 commented 10 months ago

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 commented 10 months ago

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 commented 10 months ago

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 commented 10 months ago

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 commented 9 months ago

@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 commented 9 months ago

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

camin-mccluskey commented 9 months ago

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 commented 9 months ago

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 commented 9 months ago

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