question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

RFC: React 18 SSR + Suspense Support

See original GitHub issue

This 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 be undefined

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 in ApolloClient

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:

  1. 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).
  2. If an incremental chunk returns an error, collect those errors in the error property returned from useSuspenseQuery. 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 the error 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 the error 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

Render-as-you-fetch pattern

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:

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 with useSuspenseQuery (#10324)
  • v3.8.0-alpha.2: Suspense support with useFragment
  • v3.8.0-alpha.3: Suspense support in useBackgroundQuery
  • 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

Issue Analytics

  • State:open
  • Created a year ago
  • Reactions:66
  • Comments:36 (18 by maintainers)

github_iconTop GitHub Comments

21reactions
jerelmillercommented, Dec 12, 2022

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 label react-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:

npm install @apollo/client@alpha

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!

8reactions
jerelmillercommented, Nov 4, 2022

@adamesque I dig it. I’ll get the RFC updated with the latest feedback. Thanks so much for the use case!

Read more comments on GitHub >

github_iconTop Results From Across the Web

RFC: React 18 SSR + Suspense Support - Apollo Community
Hi all :wave:t2: I wanted to call your attention to RFC: React 18 SSR + Suspense Support, which details how we plan to...
Read more >
React v18.0 – React Blog
In React 18, we've added support for Suspense on the server and expanded its capabilities using concurrent rendering features.
Read more >
React 18: React Server Components | Next.js
Basic Features. Pages. Data Fetching. Overview · getServerSideProps · getStaticPaths · getStaticProps · Incremental Static Regeneration · Client side · Built-in ...
Read more >
дэн on Twitter: "so, Next.js 13 came out today with a first beta ...
New Suspense SSR Architecture in React 18 · Discussion #37 ... RFC: First class support for promises and async/await by acdlite · Pull ......
Read more >
The future of rendering in React - Prateek Surana
Understand what are the problems with current rendering patterns in React, and how the new rendering patterns introduced with React 18 and ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found