Skip to content
Merged
83 changes: 68 additions & 15 deletions docs/framework/react/guides/advanced-ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,40 @@ The first step of any React Query setup is always to create a `queryClient` and
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
})
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient()
} else {
// Browser: make a new query client if we don't already have one
// This is very important so we don't re-make a new client if React
// supsends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}

export default function Providers({ children }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
}),
)
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient()

return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
Expand Down Expand Up @@ -338,7 +359,7 @@ As an aside, in the future it might be possible to skip the await for "optional"

While we recommend the prefetching solution detailed above because it flattens request waterfalls both on the initial page load **and** any subsequent page navigation, there is an experimental way to skip prefetching altogether and still have streaming SSR work: `@tanstack/react-query-next-experimental`

This package will allow you to fetch data on the server (in a Client Component) by just calling `useSuspenseQuery` in your component. Results will then be streamed from the server to the client as SuspenseBoundaries resolve. Note that all calls to `useSuspenseQuery` must be wrapped in a `<Suspense>` boundary somewhere further up the tree to work.
This package will allow you to fetch data on the server (in a Client Component) by just calling `useSuspenseQuery` in your component. Results will then be streamed from the server to the client as SuspenseBoundaries resolve. If you call `useSuspenseQuery` without wrapping it in a `<Suspense>` boundary, the HTML response won't start until the fetch resolves. This can be whan you want depending on the situation, but keep in mind that this will hurt your TTFB.

To achieve this, wrap your app in the `ReactQueryStreamedHydration` component:

Expand All @@ -350,8 +371,40 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import * as React from 'react'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'

function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
})
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient()
} else {
// Browser: make a new query client if we don't already have one
// This is very important so we don't re-make a new client if React
// supsends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
}

export function Providers(props: { children: React.ReactNode }) {
const [queryClient] = React.useState(() => new QueryClient())
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient()

return (
<QueryClientProvider client={queryClient}>
Expand All @@ -378,7 +431,7 @@ The downside is easiest to explain if we look back at [the complex request water

This is even worse than with `getServerSideProps`/`getStaticProps`, since with those we could at least parallelize data- and code-fetching.

If you value DX, iteration/shipping speed and low code complexity over performance, or don't have deeply nested queries and you know you are on top of your request waterfalls anyway, this can be a good tradeoff.
If you value DX/iteration/shipping speed with low code complexity over performance, don't have deeply nested queries, or are on top of your request waterfalls with parallel fetching using tools like `useSuspenseQueries`, this can be a good tradeoff.

> It might be possible to combine the two approaches, but even we haven't tried that out yet. If you do try this, please report back your findings, or even update these docs with some tips!

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { isServer } from '@tanstack/react-query'
import { useServerInsertedHTML } from 'next/navigation'
import * as React from 'react'
import { htmlEscapeJsonString } from './htmlescape'
Expand Down Expand Up @@ -139,36 +140,31 @@ export function createHydrationStreamProvider<TShape>() {
// </server stuff>

// <client stuff>
const onEntriesRef = React.useRef(props.onEntries)
React.useEffect(() => {
onEntriesRef.current = props.onEntries
})

React.useEffect(() => {
// Client: consume cache:
const onEntries = (...serializedEntries: Array<Serialized<TShape>>) => {
const entries = serializedEntries.map((serialized) =>
transformer.deserialize(serialized),
)
onEntriesRef.current(entries)
}

// Setup and run the onEntries handler on the client only, but do it during
// the initial render so children have access to the data immediately
// This is important to avoid the client suspending during the initial render
// if the data has not yet been hydrated.
Comment on lines +143 to +146
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super on top of the react-query-next-experimental library so not sure if use cases and edge cases align, but, I thought I'd note that it might be worth checking out this comment (and implementation) in the HydrationBoundary for how we handle hydration there: https://github.com/TanStack/query/blob/main/packages/react-query/src/HydrationBoundary.tsx#L35-L49

Short version, what we do there is that if data is not already in the cache, we hydrate in render, if it is, we hydrate in an effect so we don't cause side effects inside of render.

I'm not sure how likely this is to happen with the streaming library, if it can at all, but I thought I'd mention it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting callout! I think it's a different situation here, and what we're doing is safe. HydrationBoundary could happen at any part of the tree, whereas there will only be one ReactQueryStreamedHydration and it will be high up in the tree / wrapping the whole thing. This code will only be called once on boot, and we went any streamed data to be available ASAP on the initial render phase.

Definitely let me know if you see any problems with that logic!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think the whole hydration can only happen once, on the first render, where the cache is still empty. I'd give this a go as-is.

I added a small refactoring commit: bb51270

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking this across the finish line @TkDodo!

if (!isServer) {
const win = window as any
// Register cache consumer
const winStream: Array<Serialized<TShape>> = win[id] ?? []

onEntries(...winStream)

// Register our own consumer
win[id] = {
push: onEntries,
}

return () => {
// Cleanup after unmount
win[id] = []
if (!win[id]?.initialized) {
// Client: consume cache:
const onEntries = (...serializedEntries: Array<Serialized<TShape>>) => {
const entries = serializedEntries.map((serialized) =>
transformer.deserialize(serialized),
)
props.onEntries(entries)
}

const winStream: Array<Serialized<TShape>> = win[id] ?? []

onEntries(...winStream)

win[id] = {
initialized: true,
push: onEntries,
}
}
}, [id, transformer])
}
// </client stuff>

return (
Expand Down