RFC: React 18 SSR + Suspense Support
See original GitHub issueThis RFC replaces the previous conversations on React 18 SSR (#8365) and React Suspense (#9627).
Background
React 18 brings some new capabilities to SSR primarily with the help of React suspense. Among these is the ability to handle streaming HTML with the newly introduced renderToPipeableStream
function. This relies on Suspense
to determine which fallback boundaries will be returned with the initial HTML chunk.
You can find information about the architecture in this discussion along with a demo of this functionality created by the React team here. An upgrade guide is also provided to give an idea of how these APIs are used.
Technical guidance
Adding proper React 18 SSR support means adding support for React suspense (https://github.com/apollographql/apollo-client/issues/9627). Once we build out our suspense solution, this should get us most of the way to SSR support. At this time, it’s unclear to me what will be lacking in the SSR context once we implement SSR. We will be able to learn this once we start work on the feature.
Here are some ideas on how we might approach this from a high-level technical/API standpoint.
API
Suspense-enabled queries should be opt-in. Because React requires a <Suspense />
boundary to properly work when a component suspends, there will need to be code modifications. Allowing developers to opt-in would allow the developer to add suspense support at their own pace.
We want to enable the use of suspense by introducing a new hook called useSuspenseQuery
:
useSuspenseQuery(MY_QUERY, options)
Using useSuspenseQuery
will require use of a suspense cache to keep track of in-flight requests. This will be instantiated and passed to the <ApolloProvider />
component.
const suspenseCache = new SuspenseCache();
<ApolloProvider client={client} suspenseCache={suspenseCache}>
{children}
</ApolloProvider>
Supported options
The new useSuspenseQuery
hook aims to feel similar to useQuery
, so it should operate with a similar set of options. Below is the list of useQuery
options that we plan to support with useSuspenseQuery
Existing options
useQuery option |
useSuspenseQuery option |
Notes |
---|---|---|
query | ✅ | |
variables | ✅ | |
ssr | ❌ | Ideally useSuspenseQuery would “just work” with SSR so we’d like to avoid this option. We will re-evaluate with feedback over time. |
client | ✅ | |
errorPolicy | ✅ | |
onCompleted | ❌ | Because the component suspends, this option is an oddball. Until we have a use-case, we won’t support it. |
onError | ❌ | Because the component throws errors, this option is an oddball. Until we have a use-case, we won’t support it. |
context | ✅ | |
returnPartialData | ✅ | |
canonizeResults | ✅ | |
defaultOptions | ❌ | Pass the option directly |
fetchPolicy | ✅ | |
nextFetchPolicy | ✅ | |
refetchWritePolicy | ✅ | |
pollInterval | ❌ | |
notifyOnNetworkStatusChange | ❌ | |
skip | ❌ | |
partialRefetch (deprecated) | ❌ |
New options
Option name | Type | Usage |
---|---|---|
suspensePolicy | Enum; always | initial |
Determines when to suspend on a refetch. always (default) will always suspend on a refetch. initial will only suspend on the first fetch |
Notable changes in behavior from useQuery
Using a suspense query will differ from useQuery
in the following ways:
- There is no loading state
With a suspense enabled query, a promise is thrown and the nearest <Suspense />
boundary fallback is rendered. Because suspense now handles the loading state for us, we no longer need a loading
boolean returned in the result.
data
should no longer beundefined
A pending promise in a suspense query will suspend the component (i.e. throw the promise), so we can guarantee that once the data is resolved successfully, we have non-null data. Using suspense will allow a developer to remove !data
checks in render.
// Now you can just use `data` as if you had it all along!
const MyComponent = () => {
const { data } = useSuspenseQuery(MY_QUERY);
return (
<div>
{data.map((item) => <div key={item.id}>{item.value}</div>)}
</div>
);
}
- Multiple
useSuspenseQuery
hooks in the same component will result in a request waterfall
As a best practice, we should avoid recommending the use of multiple useSuspenseQuery
hooks in the same component. useSuspenseQuery
will suspend immediately, which means calls to other useSuspenseQuery
hooks in the same component won’t run until previous calls have been resolved.
const MyComponent = () => {
const { data: data1 } = useSuspenseQuery(QUERY_1);
const { data: data2 } = useSuspenseQuery(QUERY_2); // won't fetch until the first result resolves
return (
<>
<div>{data1.myQuery}</div>
<div>{data2.myOtherQuery}</div>
</>
);
}
Instead, we should recommend using separate components surrounded by a suspense boundary to fetch data in parallel.
const MyOuterComponent = () => {
return (
<React.Suspense fallback="Loading...">
<MyFirstInnerComponent />
<MySecondInnerComponent />
</React.Suspense>
);
}
const MyFirstInnerComponent = () => {
const { data: data1 } = useSuspenseQuery(QUERY_1);
return <div>{data1.myQuery}</div>;
}
const MySecondInnerComponent = () => {
const { data: data2 } = useSuspenseQuery(QUERY_2);
return <div>{data2.myOtherQuery}</div>;
}
- Error policies
As encouraged by some of the early suspense docs, rejected promises will result in errors, which means an error boundary should be used to capture the error state. If a useSuspenseQuery
fulfills with a rejected promise, we throw that error. You can see an example of this behavior in a demo provided by Kent C. Dodds via an Egghead tutorial.
Though we will throw as the default behavior, we want to enable users to have control over how errors are handled. We should respect the error policy set by the user.
Error policy | Throws the error | Notes |
---|---|---|
none |
✅ | |
ignore |
🚫 | |
all |
🚫 | As with useQuery , this will allow you to get partial data results alongside the error via the error property returned by useSuspenseQuery |
- We no longer need
getDataFromTree
With the new architecture, we no longer need our 2-pass rendering approach as React 18 SSR uses streaming HTML and suspense boundaries. We can no longer rely on this behavior since rendering is no longer synchronous while using renderToPipeableStream
.
We should consider deprecating this function (and the related renderToStringWithData
) and encourage others to migrate to renderToPipeableStream
. Perhaps this is something we ultimately move to a separate bundle for backwards compatibility in a future major version of Apollo for those that need support for synchronous rendering.
- Investigate if we can deprecate the
ssrMode
option inApolloClient
I don’t have a lot of context for what ssrMode
does under the hood, but this might be an opportunity to deprecate this flag in our options to the ApolloClient
class. If we can pull this off, this gets us a step closer to allowing user to share an Apollo client between the server and the client.
Working with the cache
How we return data from a suspense-enabled query depends on the fetch policy specified for the query. When using a fetch policy that reads from the cache, avoid suspending the component and return cached data if the data is already in the cache. For policies that avoid the cache, always suspend the component.
Fetch policies
Supported fetch policies
Fetch policy | Supported? |
---|---|
cache-first |
✅ |
cache-only |
❌ |
cache-and-network |
✅ |
network-only |
✅ |
no-cache |
✅ |
standby |
❌ |
Fetch policy behaviors
Fetch policy | Description |
---|---|
cache-first | Try to read from the cache. Suspend when there is no cached data. |
cache-and-network | Try to read from the cache while a network request is kicked off. Only suspend when there is no cached data. |
network-only | Always suspend when a network request is kicked off |
no-cache | Always suspend when a network request is kicked off |
Building a promise cache
We will need a way to cache the promises thrown by useQuery
when the hook is run. Apollo has the ability to deduplicate queries across components, which means it’s possible more than one component by rely on the same promise to resolve. We need a way to associate queries with their promises so that if components are rendered, we can look them up and throw them if necessary to suspend the component. React 18 concurrency features also may determine that a component should be re-rendered at any time. We want to ensure any suspended component that attempts to be rendered is able to properly look up a pending promise and re-throw if necessary.
We will need this to be a React-only feature, so adding this to something like QueryManager
won’t work since its part of core. Perhaps this is something we consider initializing in ApolloProvider
.
There is a RFC for a built-in suspense cache, but this is still a ways off. We will need to build our own until this is in place.
This is also particularly important if we want to enable the render-as-you-fetch pattern in Apollo.
Usage with @defer
useSuspenseQuery
should aim to take advantage of the benefits @defer
provide, namely being able to render UI when the first chunk of data is returned. Because of this, we should avoid suspending for the entirety of the request, or we risk negating the benefits of the deferred query. Instead, we should only suspend until the first chunk of data is received, then rerender as subsequent chunks are loaded. We also plan to add support for suspense in useFragment
to allow deferred chunks to suspend as they are being loaded.
Roughly, this should work as follows:
const QUERY = gql`
query {
greeting {
message
... @defer {
recipient {
name
}
}
}
}
`;
// 1. Issue the query and suspend
const { data } = useSuspenseQuery(QUERY);
// 2. When the initial chunk of data is returned, un-suspend and return the first chunk of data
const { data } = useSuspenseQuery(QUERY);
/*
data: {
greeting: {
__typename: 'Greeting',
message: 'Hello world'
}
}
*/
// 3. Rerender when the deferred chunk resolves
const { data } = useSuspenseQuery(QUERY);
/*
data: {
greeting: {
__typename: 'Greeting',
message: 'Hello world',
recipient: {
__typename: 'Person',
name: 'Alice'
}
}
}
*/
Usage with fetch policies
If using a fetch policy that reads from the cache, we should still try and read the entirety of the query from the cache. If we do not have the data, or only have partial data, fetch and suspend like normal. Leverage the returnPartialData
option if you’d like to avoid suspending when partial data is in the cache.
Error handling
Error handling is trickier since the initial chunk could succeed while deferred chunks return with errors. I propose the following rules:
- If the initial chunk returns an error, treat it the same as if the query was issued without a deferred chunk. The behavior would depend on the
errorPolicy
set in options (see above for more detail on error policies). - If an incremental chunk returns an error, collect those errors in the
error
property returned fromuseSuspenseQuery
. Do not throw an error as the initial chunk should be allowed to render.
- When the errorPolicy is set to
none
(the default), discard any partial data results and collect all errors in theerror
property - When the errorPolicy is set to
ignore
, discard all partial data results and errors - When the errorPolicy is set to
all
, add all partial data results and collect all errors in theerror
property
SSR
The above implementation should get us most, if not all the way, there for SSR support. The one real outstanding question is how we populate the cache client-side once SSR completes so that we can avoid a refetch. See the Outstanding Questions
section below for more information on this question.
Other considerations
Up to this point, our Apollo client supports the fetch-on-render pattern, which might introduce request waterfalls depending on how an app is structured. With the introduction of Suspense, we should be able to enable the render-as-you-fetch pattern, which allows data to be loaded ahead of time so that we can begin to render a component as data is being fetched.
This is a pattern we should explore as its now the recommended approach since it allows us to show content to the user sooner and load data in parallel.
Outstanding questions
How do we hydrate the client cache with data fetched server-side?
Our current solution fetches all data in a 2-pass approach via getDataFromTree
. Using this method, we are able to detect when all queries have resolved before we send the rendered HTML. Because we are using synchronous rendering APIs, we are able to detect when rendering is complete and send the markup complete with the extracted cache data on a global variable.
React 18 makes this a lot trickier as the new architecture allows for the ability to stream HTML while the client begins to hydrate. Waiting for React to fully finish streaming the HTML in order to restore the client Apollo cache feels too late as its possible hydrated components may already begin to execute on the client.
On the flip side, React 18’s new renderToPipeableStream
does include an onShellReady
callback but it appears this might fire too early. From the docs:
onShellReady() {
// The content above all Suspense boundaries is ready.
// If something errored before we started streaming, we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
I come across this discussion with some interesting comments about data hydration:
Hydration Data
You don’t just need code to hydrate. You also need the data that was used on the server. If you use a technique that fetches all data before rendering you can emit that data in the bootstrapScriptContent option as an inline script. That way it’s available by the time the bootstrap script starts hydrating. That’s the recommended approach for now.
However, with only this technique streaming doesn’t do much anyway. That’s fine. The purpose of these guides is to ensure that old code can keep working, not to take full advantage of all future functionality yet.
The real trick is when you use Suspense to do you data fetching during the stream. In that case you might discover new data as you go. This isn’t quite supported in the React 18 MVP. When it’s fully supported this will be built-in so that the serialization is automatic.
However, if you want to experiment with it. The principle is the same as the lazy scripts. You start hydrating as early as possible with just a placeholder for the data. When the placeholder is missing data, and you try to access it, it suspends. As more data is discovered on the server, it’s emitted as script tags into the HTML stream. On the client that is used as a signal to unsuspend the accessed data.
I believe this is something we will continue to learn about as we begin implementation.
@benjamn has proposed an interesting idea of potentially transmitting the whole query results produced via SSR rather than normalized cache results that would need to be serialized. Queries might then be able to use these query results immediately which would then make their way into the cache. This is something we should consider via an ApolloLink
.
What other hooks allow a component to suspend?
useLazyQuery
We have this example in our docs:
import { gql, useLazyQuery } from "@apollo/client";
const GET_GREETING = gql`
query GetGreeting($language: String!) {
greeting(language: $language) {
message
}
}
`;
function Hello() {
const [loadGreeting, { called, loading, data }] = useLazyQuery(
GET_GREETING,
{ variables: { language: "english" } }
);
if (called && loading) return <p>Loading ...</p>
if (!called) {
return <button onClick={() => loadGreeting()}>Load greeting</button>
}
return <h1>Hello {data.greeting.message}!</h1>;
}
This implies that the general usage of useLazyQuery
is in response to user interaction. Because of this, I’m inclined to say that we not add suspense support for useLazyQuery
and let it operate as it does today to allow for a better user experience. If we decided to suspend the component, this would result in the already displayed UI being unmounted and the suspense fallback displayed instead. This seems to be more in line with how startTransition
works within suspense as it avoids rendering the suspense fallback when possible.
That being said, I wonder if we should consider using startTransition
to allow React to determine whether to suspend or not (if possible). Per the docs:
Updates in a transition will not show a fallback for re-suspended content, allowing the user to continue interacting while rendering the update
Its unclear to me how exactly this works if the same hook allows the component to suspend, but would be interesting to explore nonetheless.
useFragment
This component interacts directly with the cache and never the network. Because of this, we should not add suspense support for this hook.
useMutation
This is a write operation and therefore should not suspend a component. Mutations are typically used in response to user interaction anyways. This behavior would be consistent with a library like Relay which only uses suspense for queries.
Existing SSR support
Our current solution uses an exported function called getDataFromTree
that allows us to use a 2-pass rendering approach. This relies on React synchronous rendering via renderToStaticMarkup
and attempts to wait until all data is fetched in the React tree before resolving (renderToString
usage is also available via renderToStringWithData
)
This is NOT affected in React 18 and will continue to work, though it is not the recommended solution. In React 17, any use of Suspense
with renderToString
or renderToStaticMarkup
would result in an error. In React 18, this changed and these functions now support very limited Suspense
support.
You can see a the existing functionality working with React 18 in a demo I put together.
Communication
React 18 SSR suspense support (and broad data-fetching support) is still very experimental. There are no formal guidelines yet given by the React team, so adding support means we will need to be reactive to breaking changes within React itself. Adding suspense to Apollo however might encourage the React team to move this functionality out of beta.
Once we go live with this functionality, assuming the React team hasn’t introduced formal guidelines, we will need a way to communicate this to our broader audience. Below are some references to how other libraries do this:
- https://relay.dev/docs/migration-and-compatibility/suspense-compatibility/#is-suspense-for-data-fetching-ready-yet
- https://github.com/FormidableLabs/react-ssr-prepass
- https://tanstack.com/query/v4/docs/guides/suspense
I particularly like the way React Query states this:
These APIs WILL change and should not be used in production unless you lock both your React and React Query versions to patch-level versions that are compatible with each other.
Support for suspense in other libraries
This shows the current landscape of client suspense + SSR suspense support in other libraries. In this context, client suspense means it takes advantage of <Suspense />
boundaries for data fetching and SSR suspense means that it supports React 18 SSR features (i.e. streaming HTML via renderToPipeableStream
with <Suspense />
boundaries)
Library | Client suspense | SSR suspense | Reference | Notes |
---|---|---|---|---|
urql |
✅ | ✅ | https://formidable.com/open-source/urql/docs/advanced/server-side-rendering/#using-react-ssr-prepass | react-ssr-prepass is currently advised, but also works with renderToPipeableStream . Demo |
GQty | ✅ | ⚠️ | https://gqty.dev/docs/react/fetching-data#suspense-example | Advises react-ssr-prepass for a 2-pass rendering approach |
Relay | ✅ | ✅ | https://relay.dev/docs/guided-tour/rendering/loading-states/ | This is the only vetted approach so far by the React team. |
React Query | ✅ | 🚫 | https://tanstack.com/query/v4/docs/guides/suspense | |
SWR | ✅ | 🚫 | https://swr.vercel.app/docs/suspense | |
micro-graphql-react |
✅ | ❓ (unclear from docs) | https://arackaf.github.io/micro-graphql-react/docs/#react-suspense | |
Next.js | ✅ | 🚫 | https://nextjs.org/docs/advanced-features/react-18/streaming#data-fetching | Does not currently support SSR, but there is an open RFC detailing how they plan to support it. |
Release strategy
We will gradually release suspense support in a series of v3.8 alpha releases.
-
v3.8.0-alpha.0
:useSuspenseQuery
(#10323) -
v3.8.0-alpha.1
:@defer
support withuseSuspenseQuery
(#10324) -
v3.8.0-alpha.2
: Suspense support withuseFragment
-
v3.8.0-alpha.3
: Suspense support inuseBackgroundQuery
-
v3.8.0-alpha.4
: React 18 SSR support
NOTE: Alpha versions may not align with the actual released versions. These are subject to change at any time. The goal is to communicate a rough timeline.
References
- https://github.com/reactwg/react-18/discussions/37
- https://github.com/reactwg/react-18/discussions/22
- React Suspense beta docs
- React 18 announcement
- https://17.reactjs.org/docs/concurrent-mode-suspense.html
- https://www.apollographql.com/docs/react/performance/server-side-rendering
- https://github.com/jerelmiller/react-apollo-ssr
- https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md
- https://github.com/reactwg/react-18/discussions/114
- Relay example
- Suspense and Error Boundaries in React 18 (video)
- Removing State and Effects with Suspense! (video)
- ReactConf 2021 Keynote (video)
Issue Analytics
- State:
- Created a year ago
- Reactions:66
- Comments:36 (18 by maintainers)
Top GitHub Comments
Hey all 👋 ! Some exciting news!
We’ve just released our first
alpha
version with Suspense support! I’d love for you all to try it out and provide feedback. To keep comments in this issue focused on the RFC, I’d encourage you to open new issues for bug reports using the labelreact-suspense
. Comments and feedback about the overall API design are ok in this issue as they are related to the overall Suspense strategy.To install:
As noted in our “Release Strategy” in the RFC, this includes baseline
useSuspenseQuery
support.@defer
and SSR support will be coming in future alpha releases.For more information on what was released and how to use
useSuspenseQuery
, see the merged PR!@adamesque I dig it. I’ll get the RFC updated with the latest feedback. Thanks so much for the use case!