apollographql / apollo-client-nextjs

Apollo Client support for the Next.js App Router
https://www.npmjs.com/package/@apollo/experimental-nextjs-app-support
MIT License
358 stars 25 forks source link

Fetching data on RSC and hydrate the client with that data #124

Open nickbling opened 6 months ago

nickbling commented 6 months ago

Hello,

I'm building a page that displays music albums, has an infinite loader and also a bunch of filtering options. I'm using Next.js v13.5.4 with the app dir, and Contentful to retrieve my data. In order to setup Apollo, I followed the guidelines reported here.

What I'd like to do is:

At the moment I'm handling the process like this:

// page.tsx --> RSC

const MusicCollection: FC = async () => {
  await getClient().query<AlbumCollectionQuery, AlbumCollectionQueryVariables>({
    query: ALBUM_COLLECTION_QUERY,
    variables: initialAlbumListFilters,
  })

  return <AlbumCollection />
}

export default MusicCollection
// AlbumCollection.tsx --> Client Component

'use client'

import { useSuspenseQuery } from '@apollo/experimental-nextjs-app-support/ssr'

export const AlbumCollection: FC = async () => {
  const { data, fetchMore, refetch } = useSuspenseQuery<
    AlbumCollectionQuery,
    AlbumCollectionQueryVariables
  >(ALBUM_COLLECTION_QUERY, {
    fetchPolicy: 'cache-first',
    variables: initialAlbumListFilters,
  })

// ...
}

My idea was that if I made the query on the RSC first, the client would have populated itself without making the query another time, but that's not happening and I don't think I understood how this should properly work. Since there are not many topics about it around, could you help me understand the correct workflow to follow for these use cases?

Thank you

phryneas commented 6 months ago

The caches in Server Components and in Client Components are completely independent from each other - nothing from the RSC cache will ever be transported to the CC cache. (As for a general rule, Server Components should never display entities that are also displayed in Client Components).

You could try to do something like

// RSC file
export async function PrefetchQuery({query, variables, children}) {
  const { data } = await getClient().query({ query, variables })

  return <HydrateQuery query={query} variables={variables} data={data}>{children}</HydrateQuery>

}

// CC file
"use client";
export function HydrateQuery({query, variables, data, children}) {
  const hydrated = useRef(false)
  const client = useApolloClient()
  if (!hydrated.current) {
    hydrated.current = true;
    client.writeQuery({ query, variables, data })
  }
  return children
}

and then use it like

<PrefetchQuery query={ALBUM_COLLECTION_QUERY} variables={initialAlbumListFilters}>
  <AlbumCollection/>
</PrefetchQuery>

to transport the result over, but to be honest, I haven't experimented around with this too much yet.

pondorasti commented 6 months ago

(As for a general rule, Server Components should never display entities that are also displayed in Client Components).

I see your reasoning for creating a separation between RSCs and CCs from a technical perspective. However, thinking about this from the end user, this statement is inherently saying that you can either use Apollo for highly static (RSCs) or dynamic (CCs) cases. However, most apps nowadays sit somewhere in the middle where they are aiming for dynamic at the speed of static experiences. In order to achieve this, you would need to make your RSCs and CCs work together.

Using getClient inside an RSC to kickoff a request as soon as possible, and then hydrating a CC for adding interactivity seems like something that should work directly in Apollo.

<Suspense fallback={<Skeleton />}>
  <PrefetchQuery query={...}>
    <ClientComponent />
  </PrefetchQuery>
</Suspense>

If you were to rewrite the code above by removing PrefetchQuery and only use useSuspenseQuery, it would lead to a slower experience in almost all cases. (since the network request gets waterfalled and triggered from the browser).

Putting that aside, I was running into the same issue when implementing infinite scrolling. Initially tried a cursed pattern by recursively returning RSCs, but kept running into problems with streaming or nextjs. Ended up implementing a solution similar to the one above. The only caveat, is that not all data is serializable and I need to use JSON.stringify/parse when passing data between the RCS and CC.

Warning: Only plain objects can be passed to Client Components from Server Components. Location objects are not supported.
  {kind: ..., definitions: [...], loc: Location}
phryneas commented 5 months ago

@Pondorasti our statements do not collide.

I said: "Server Components should never display entities that are also displayed in Client Components"

As long as you don't display those contents in your server components, but just somehow forward them to the cache of your client components, you are fine.

But if you render them out in a server component, and also render them out in a client component, and then the cache changes (as it does, because it is a dynamic normalized cache), your client components would all rerender with the new contents and your server components would stay as they are, because they are static HTML. You want to avoid that at all costs.

As for your problem at hand: we've been using the superjson library for that kind of serialization, and so far it worked well.

pondorasti commented 5 months ago

I said: "Server Components should never display entities that are also displayed in Client Components"

As long as you don't display those contents in your server components, but just somehow forward them to the cache of > your client components, you are fine.

Thanks for the detailed explanation and my apologies for the misunderstanding. This makes perfect sense. So what's the story for hydrating CCs from RSCs. Is something like a <PrefetchQuery /> component planning to be built at the framework level?

As for your problem at hand: we've been using the superjson library for that kind of serialization, and so far it worked well.

Great choice, could even pass those pesky date objects around and still work as expected. Thanks for the tip 🙌🏻

phryneas commented 5 months ago

Thanks for the detailed explanation and my apologies for the misunderstanding. This makes perfect sense. So what's the story for hydrating CCs from RSCs. Is something like a component planning to be built at the framework level?

I've been thinking about it since we released the library, and I'm not 100% certain on what exactly the API should look like in the end (e.g. a more sophisticated variant might not need to wrap the children) - also it seemed that something on the React/Next.js side might be moving a bit, so I've held back more there, too (it seems like we could use taint to maybe ensure these values never get rendered in RSC? 🤔 ).

So yeah.. it's on my mind, and it will come eventually, but I'm also very busy with other parts of Apollo Client right now (including trying to get native support for all this into React so we won't need framework-specific wrapper packages anymore), so I can't guarantee for a timeline.

pondorasti commented 5 months ago

Awesome, thanks for sharing all these info with me. Looking forward to the changes, and will definitely keep an eye out for the RFC once you've figured out 😉.

martingrzzler commented 3 months ago

Any updates on this?

martingrzzler commented 3 months ago

@phryneas react-query has something like this implemented. <HydrationBoundary/> What do you think of their approach?

// app/posts/page.jsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'

export default async function PostsPage() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })

  return (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

and then the posts are immediately available on the client

// app/posts/posts.jsx
'use client'

export default function Posts() {
  // This useQuery could just as well happen in some deeper
  // child to <Posts>, data will be available immediately either way
  const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix.
  const { data: commentsData } = useQuery({
    queryKey: ['posts-comments'],
    queryFn: getComments,
  })

  // ...
}