I have a reproduction for this issue here.
I’m playing around with NextJS app router, and Suspense.
I have two implementations of a simple client component that fetches data with React Query. One uses useSuspenseQuery
the other uses a regular query.
export function TodosRq() {
const query = useQuery<Array<{id: number, title: string}>>({ queryKey: ['todos'], queryFn: async () => {
await new Promise((res) => setTimeout(res, 10000));
const res = await fetch("https://jsonplaceholder.typicode.com/todos")
return res.json();
} })
return <div>
{query.data?.map((v) => {
return <div>
RQ
{v.id} {v.title}
</div>
})}
</div>
}
export function TodosRqSuspense() {
const query = useSuspenseQuery<Array<{id: number, title: string}>>({ queryKey: ['todos'], queryFn: async () => {
await new Promise((res) => setTimeout(res, 10000));
const res = await fetch("https://jsonplaceholder.typicode.com/todos")
return res.json();
} })
return <div>
{query.data.map((v) => {
return <div>
RQ
{v.id} {v.title}
</div>
})}
</div>
}
In my App router page, I can render either of these components:
{/* nb. this suspense boundary won't do anything */}
<Suspense fallback={'rq loading'}>
<h2>Todos RQ</h2>
<TodosRq/>
</Suspense>
or
<Suspense fallback={'rq loading'}>
<h2>Todos RQ</h2>
<TodosRqSuspense/>
</Suspense>
Intuitively, what I’m expecting here is that server render will render application in it’s loading state, stream that to the client, and then the client takes over and makes the API call.
However, what I actually observe, is that in the case of using the suspense query, actually NextJS applies static rendering to TodosRqSuspense
component. That is, in a production build, it returns prerendered HTML, never making the 10 second wait.
It’s important to observe both the behaviour of the dev server, as well as the production build.
Dev Server | Production Build | |
---|---|---|
TodosRq | We don’t see the suspense boundary. We wait 10 seconds till content appears. Content does not appear in the root document. | We don’t see the suspense boundary. We wait 10 seconds till content appears. Content does not appear in the root document. |
TodosRqSuspense | We see the suspense boundary. We wait 10 seconds till content appears. Content appears in the root document. (The dev server appears to do something funny where it can modify the response body of the network request) | We get the content immediately. Content is in the root document. |
What am I missing here?
Here’s the relevant parts of the documentation and how I understand it:
Static vs Dynamic rendering – for server components default behaviour is that all content will statically rendered (that is, rendered at build time), even if it involves data fetching, unless it fits into one of the exceptions, such as using the cookies
or connection
methods.
Client components – Client components are pre-rendered on the server, (first render on the server), and but then when they hit the client then the work is done on the client.
Suspense – Allows the ‘first render’ to show all the loading skeletons etc, and then if using RSCs then they’ll stream in, and, this is where I am having some misunderstanding, I would have thought that client components would still do their data fetching client side, and then display when they are complete.
What NextJS recommends – NextJS recommend that you do your data fetching in server components, just to be clear. However – within the context of some kind of migration, it makes sense that we might keep some things as client components. In any case, I’m trying to understand the nuance here.
nb. if we add useSearchParams
to our client component, then this behaviour does not occur and behaves inline with how I’ve intuited.
2