Hydration error using loading state of useQuery when prefetching
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
- Prefetch something on the server
- Hydrate it to the client
- Use
useQueryin the client, and have a loading fallback withisLoading.
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
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.
@Ephem this seems to be related to tryResolveSync that was recently added. See the linked trpc issue for more info please.
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.
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
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. 🤷
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.
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.
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
-
ClientProviderruns →createQueryClient()creates QueryClient B (new, empty cache!) - Render
PageClient(uses QueryClient B) → Cache is empty →useQuerystarts 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 foramountunderlocalhost):
<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>
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’suseMemo→ 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
- Multiple
QueryClientinstances are created independently, fragmenting state across them. - There’s a collision (or unintended interaction) between the RSC payload and React Query’s
tryResolveSyncbehavior.
@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
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.
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?