query icon indicating copy to clipboard operation
query copied to clipboard

Hydration error using loading state of useQuery when prefetching

Open Icestonks opened this issue 7 months ago • 10 comments

Describe the bug

If you prefetch data on the server and then use useQuery with isLoading on the client, it can lead to a hydration error.

Your minimal, reproducible example

https://stackblitz.com/~/github.com/Icestonks/react-query-trpc-test

Steps to reproduce

  1. Prefetch something on the server
  2. Hydrate it to the client
  3. Use useQuery in the client, and have a loading fallback with isLoading.

Then there should come a hydration error.

Expected behavior

When first building and starting the project, we should see an hydration error. The error can also come at other times, but that's the most reliable way, to get the error to occur.

How often does this bug happen?

Sometimes

Screenshots or Videos

Image

Platform

  • OS: Windows
  • Browser: Google Chrome
  • Browser version: 138.0.7204.100

Tanstack Query adapter

react-query

TanStack Query version

5.82.0

TypeScript version

5.8.3

Additional context

Initially, the issue was thought to be related to tRPC, so there has been some discussion about the error over there: https://github.com/trpc/trpc/issues/6740

Would highly recommend reading some of the comments from that issue. Could maybe also give some more context.

Icestonks avatar Jul 10 '25 01:07 Icestonks

@Ephem this seems to be related to tryResolveSync that was recently added. See the linked trpc issue for more info please.

TkDodo avatar Jul 12 '25 06:07 TkDodo

Yeah, I’m pretty positive that’s the cause. 😞 Suspense kind of acts like a synchronising factor so the SSR pass and hydration has the same status for the promise, but when Suspense is not used things clearly fail.

I need to think some more on it, but my first naive idea for a fix is treating all useQuery as pending during SSR+hydration if that query was hydrated via a promise (which I think was the case for all queries like this before tryResolveSync, which instead was a problem with Suspense).

This might be the time when we’ll want to implement getServerSnapshot in uSES. 🤔 That would have pending but the getSnapshot would have success and React would patch things up correctly.

Ephem avatar Jul 12 '25 19:07 Ephem

This might be the time when we’ll want to implement getServerSnapshot in uSES

we already have an issue for this for quite some time 😅

  • #4690

TkDodo avatar Jul 13 '25 14:07 TkDodo

It’s been on my list forever but this is the first time I’ve seen an issue related to it so it hasn’t had a very high prio in my mind. I’m sure we’ve has the occasional rare hard to catch issue stemming from it not being implemented before this though. 🤷

Ephem avatar Jul 13 '25 19:07 Ephem

Hey @Ephem, do you think this is something that's going to be solved in the near future? Or should I just wait with prefetching, until the issue is fixed.

Icestonks avatar Jul 25 '25 23:07 Icestonks

I'm currently on vacation so it might take a bit longer, but this is definitely at the top of my OSS list of things to fix.

Ephem avatar Jul 30 '25 09:07 Ephem

I briefly looked into the issue. It’s really interesting…

I added a few debugging logs to the reporter’s code and checked the results.

"use client";

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getAmountOfUsers } from "./fetcher";
import { useEffect, useState } from "react";

const PageClient = ({}) => {
  console.log("=== PageClient Render START ===");

  const queryClient = useQueryClient();
  const cache = queryClient.getQueryCache().find({
    queryKey: ["users", "amount"],
  });

  console.log("!!! Cache:", {
    status: cache?.state.status,
    data: cache?.state.data,
    promise: cache?.promise,
    promiseType: typeof cache?.promise,
  });

  console.log("@@@ Before useQuery:", {
    cacheExists: !!cache,
    cacheState: cache?.state.status,
    cacheData: cache?.state.data,
  });

  const query = useQuery(getAmountOfUsers());

  console.log("### After useQuery:", {
    isLoading: query.isLoading,
    data: query.data,
    fetchStatus: query.fetchStatus,
  });

  console.log("=== PageClient Render END ===");

  return (
    <div>
      Amount of users:{" "}
      {query.isLoading ? <em>Loading...</em> : query.data?.amount}
    </div>
  );
};

export default PageClient;

I naturally expected that since the server prefetches and hydrates before sending to the client, if the server’s data is undefined, the client would also have undefined, and if the server has data, the client would have it as well.

But the results were as follows

// client
=== PageClient Render START ===
page-client.tsx:15 !!! Cache: {status: 'success', data: {…}, promise: Promise, promiseType: 'object'}data: {amount: 12}promise: Promise {<fulfilled>: {…}, status: 'fulfilled', value: {…}}promiseType: "object"status: "success"[[Prototype]]: Object
page-client.tsx:22 @@@ Before useQuery: {cacheExists: true, cacheState: 'success', cacheData: {…}}cacheData: {amount: 12}cacheExists: truecacheState: "success"[[Prototype]]: Object
page-client.tsx:30 ### After useQuery: {isLoading: false, data: {…}, fetchStatus: 'fetching'}data: {amount: 12}fetchStatus: "fetching"isLoading: false[[Prototype]]: Object
page-client.tsx:36 === PageClient Render END ===

// server
!!! Cache: {
  status: 'pending',
  data: undefined,
  promise: Promise {}
  promiseType: 'object'
}

@@@ Before useQuery: { cacheExists: true, cacheState: 'pending', cacheData: undefined }
### After useQuery: { isLoading: true, data: undefined, fetchStatus: 'fetching' }
=== PageClient Render END ===

Sometimes it was undefined on the server, yet the client had data.

I thought about why this might be possible.

Possible hypothesis => // 1. RSC (React Server Component) stage

  • Render Layout
  • Run page.tsx (server component) → getServerQueryClient() creates QueryClient A → prefetchQuery() runs (queryFn execution "1") → dehydrate(QueryClient A) = serialize data

// 2. SSR (Server Side Rendering) stage

  • ClientProvider runs → createQueryClient() creates QueryClient B (new, empty cache!)
  • Render PageClient (uses QueryClient B) → Cache is empty → useQuery starts fetch (queryFn execution "2") → status: 'pending', isLoading: true → HTML: <em>Loading...</em>

// 3. RSC payload generation (ref: https://nextjs.org/docs/app/getting-started/server-and-client-components#on-the-client-first-load)

  • Next.js injects the result of queryFn 2 into the RSC payload → e.g., {"amount":14} This is what I actually observed (you can find this injection in the Network tab > search for amount under localhost):
        <script>
            self.__next_f.push([1, "64:I[\"[project]/node_modules/.pnpm/[email protected][email protected][email protected][email protected]/node_modules/next/dist/lib/metadata/generate/icon-mark.js [app-client] (ecmascript)\",[\"/_next/static/chunks/4787e_next_dist_c67751b4._.js\",\"/_next/static/chunks/src_app_favicon_ico_mjs_4ae240c2._.js\"],\"IconMark\"]\n60:[]\n61:[]\n62:[[\"Array.map\",\"\",0,0,0,0,false]]\n63:[]\n3e:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Create Next App\"},\"$3b\",\"$60\",0],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Generated by create next app\"},\"$3b\",\"$61\",0],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico?favicon.45db1c09.ico\",\"sizes\":\"256x256\",\"type\":\"image/x-icon\"},\"$3b\",\"$62\",0],[\"$\",\"$L64\",\"3\",{},\"$3b\",\"$63\",0]],\"error\":null,\"digest\":\"$undefined\"}\n52:\"$3e:metadata\"\n59:{\"amount\":12}\n"])
        </script>
Image

Then the client state becomes // 1. Initial state

  • ClientProvider: creates QueryClient C (new)
  • HydrationBoundary: receives the dehydrated state from QueryClient A

// 2. During hydration

  • In HydrationBoundary’s useMemo → Finds new queries → hydrates immediately → Creates a Promise-like object → Fills it with data from the RSC payload

// 3. React Query v5 tryResolveSync

  • Detects an already resolved Promise-like object
  • Extracts data synchronously!
  • status: 'success', data: { amount: 14 }

// 4. First render

  • isLoading: false
  • DOM shows "14"
  • React: HTML mismatch! (<em>Loading...</em> vs "14")

In summary, the core points seem to be

  1. Multiple QueryClient instances are created independently, fragmenting state across them.
  2. There’s a collision (or unintended interaction) between the RSC payload and React Query’s tryResolveSync behavior.

joseph0926 avatar Aug 17 '25 05:08 joseph0926

@joseph0926 Your hypothesis is full of misconceptions and is not at all what is happening. I wont go through it all, but:

  • With or without hydration, useQuery does not run during SSR
  • The dehydrated state comes from the RSC pass, not the SSR pass

Ephem avatar Sep 21 '25 19:09 Ephem

I think https://github.com/TanStack/query/issues/9642 might be related. I found it easier to start debugging there and posted a little update what I think might be going on. Not 100% sure this one has the exact same mechanism, but I'll try to verify everything when I can.

Ephem avatar Sep 21 '25 19:09 Ephem

Hi, I think I am experiencing this exact issue, where a prefetch on the server first SSRs a "loading" state because of query.isLoading. Then, at time of hydration the data is already available and renders the new tree with the correct loaded state, causing a hydration mismatch. Has there been any progress on this? Any workarounds until this is fixed?

dBianchii avatar Oct 01 '25 17:10 dBianchii