Hydration error thrown when using `PersistQueryClientProvider` and `Suspense`
Describe the bug
My real use-case:
- A modern Next.js app with authentication
- The authentication is completely offloaded to the browser
- The server does not have an auth token (a JWT, but knows if the user is potentially logged in if a refresh token is present)
- When logged in, the server returns loaders for the UI parts that are user specific
- The browser replaces them with actual data
- The browser caches data in
localStorageusingPersistQueryClientProviderandcreateAsyncStoragePersister
However, the example attached isn't that complex, it's a minimal example which reproduces the issue.
For the case above, the browser might have instant access to cached authenticated data. For instance, let's say we want to print the user's email address. The user has logged in earlier and this data is cached in localStorage, and as mentioned above, the server doesn't have this data. This is what happens:
- Both the server and the client call the same
useQuery. - Server: Since this data is not prefetched, the
statusis alwayspendingin the server, and a loader is returned - Client: The client hydrates, but it instantly has access to the cached
userand prints the email address
There is a mismatch here since the server rendered loading, while the client the email address. This case seems to be handled well when a component is not in a Suspense, but when it is, the last step will throw hydration errors (whether that's a loading.tsx or just a manual <Suspense>).
I have added some logs, and here is what I get:
Component not in Suspense:
not in suspense {status: 'pending', data: undefined, isRestoring: true}
not in suspense {status: 'success', data: {…}, isRestoring: false}
Component in Suspense:
in suspense {status: 'success', data: {…}, isRestoring: true}
in suspense {status: 'success', data: {…}, isRestoring: false}
When the component within a Suspense is rendering for the first time, the status is success, which renders data differently from the server, which I believe is the cause of the hydration error.
From the repo, here is part of the code that handles the loading UI:
const { data, status } = useSomeData();
if (status === "pending") {
return <main>loading</main>;
}
The workaround is:
const { data, status } = useSomeData();
const isRestoring = useIsRestoring();
if (status === "pending" || isRestoring) {
return <main>loading</main>;
}
That works, and does not throw hydration errors. But I feel that is bad DX, and I would prefer not to have to do that for every query call.
Your minimal, reproducible example
https://github.com/ali-idrizi/react-query-suspense-hydration-error
Steps to reproduce
Unfortunately it's a bit difficult to test this on CodeSandbox, as it doesn't seems to throw any hydration errors for some reason. Even rendering a simple {typeof window === 'undefined' ? 'x' : 'y'} does not throw an error for me. I have attached a repo instead, and you might have to run this locally, but there is also a video recording below.
To see the error, you will have to load the page once, so the data gets cached in the browser, afterwards the issue occurs on every subsequent reload.
You can try:
- Remove the
loading.tsxfile and notice no hydration errors thrown - Wrap the
SomeDatacomponent inlayout.tsxwith aSuspenseand notice the hydration error count increase
Expected behavior
Perhaps keep status as pending while isRestoring === true, similar to when a component is not within a Suspense?
How often does this bug happen?
Every time
Screenshots or Videos
https://github.com/TanStack/query/assets/20397725/4581ed80-60b1-4b36-b2b3-d16136f25bc1
Platform
- Windows 10 (WSL)
- Chrome 119
Tanstack Query adapter
react-query
TanStack Query version
5.12.2
TypeScript version
5.2.2
Additional context
No response
I'm curious as to why the workaround "works". When queries are in restoring state, they should also be in pending state.
So status === "pending" || isRestoring should be exactly the same as status === 'pending'. I can see in the video that you have a case with isRestoring: true and some data available, where I'm not sure how this is possible, given that you don't server-side-render any data? Where is that data coming from - what value does it have? Is it data from the localstorage where isRestoring is also true? Because that shouldn't happen - when data is first available, isRestoring should already be false.
also, please try not creating the QueryClient in memo, but in state:
https://github.com/ali-idrizi/react-query-suspense-hydration-error/blob/b9c6ba928e6464282225827ef184369b1d6926d4/src/lib/providers/query.tsx#L13
https://tkdodo.eu/blog/use-state-for-one-time-initializations
When queries are in restoring state, they should also be in pending state.
That's what I was thinking too, but as you can see from the logs, the data is indeed present and status success even though isRestoring is still true.
given that you don't server-side-render any data? Where is that data coming from - what value does it have?
In the repo, it's a simple function with some sleep:
const getSomeData = async () => {
await new Promise((resolve) => setTimeout(resolve, 2500));
return {
message: "We got a message!",
};
};
The hook that consumes it:
export const useSomeData = () => {
return useQuery({
queryKey: ["some", "data"],
queryFn: () => getSomeData(),
});
};
And the component that uses the custom hook:
export const SomeData: FC<Props> = ({ header, inSuspense }) => {
const { data, status } = useSomeData();
const isRestoring = useIsRestoring();
console.log(inSuspense ? "in suspense" : "not in suspense", {
status,
data,
isRestoring,
});
if (status === "pending")
return <main>loading</main>;
if (status === "error")
return <main>error</main>;
return ...
};
In my real use-case this data cannot be prefetch and dehydrated in the server, so it's only fetched in the browser, Meaning the query in the server will always be in pending state.
The component SomeData above, is rendered twice, once in a suspense and once outside. The one that's in a Suspense, is the one that throws the hydration error. The other one works great!
As you can see in the screen recording, the first load takes longer to render as the data is not cached. The second reload is much faster, as it's reading from localStorage.
please try not creating the QueryClient in memo
That's a good point. I have pushed a commit for this and tested it, but the issue still remains. Thanks for your reply.
our code is this:
setIsRestoring(true)
persistQueryClientRestore(options).then(async () => {
try {
await refs.current.onSuccess?.()
} finally {
setIsRestoring(false)
}
})
-
persistQueryClientRestoreuses hydration to write to the cache - it's async because restoring the client can be asynchronous
- we then also await the
onSuccesscallback.
So yeah I can see that there can be some render cycles where persistQueryClientRestore has already finished hydration, but isRestoring is still true. Thus, the component is rendering in success state. Not really sure how to stop that, maybe @Ephem has an idea?
Of course, blocking rendering completely with a PersistGate until restoring has completed is always an option:
import { useIsRestoring } from '@tanstack/react-query'
export function PersistGate({ children, fallback = null }) {
const isRestoring = useIsRestoring()
return isRestoring ? fallback : children
}
put that inside the Provider, but around your app:
<PersistQueryClientProvider>
<PersistGate fallback="restoring from storage ...">
<App />
</PersistGate>
</PersistQueryClientProvider>
I'm not sure if there is a better way here
Since isRestoring has true as initialState (so it's true during server rendering), means the server never returns any HTML. That would certainly solve the hydration errors, but it's not really a good option. I hope there's a better fix for this.
means the server never returns any HTML.
that's pretty much the reason why the PersistGate isn't the default behaviour. I just don't understand what difference suspense makes here, and why we get this non-batched update here ...
An even weirder thing, if you look at the entire logs in the linked repo:
not in suspense {status: 'pending', data: undefined, isRestoring: true}
not in suspense {status: 'success', data: {…}, isRestoring: false}
in suspense {status: 'success', data: {…}, isRestoring: true}
in suspense {status: 'success', data: {…}, isRestoring: true}
not in suspense {status: 'success', data: {…}, isRestoring: false}
in suspense {status: 'success', data: {…}, isRestoring: false}
not in suspense {status: 'success', data: {…}, isRestoring: false}
in suspense {status: 'success', data: {…}, isRestoring: false}
isRestoring is false in the second log, but in the third log when the component in suspense is rendering for the first time, it flips back to true. I am not sure what is happening here, but perhaps it can point to something?