-
Notifications
You must be signed in to change notification settings - Fork 49.3k
Description
@gaearon asked me to open an issue on this, so I could add a bit more context than fits into a twitter thread.
This all has come up within the last few days that I have been trying out the NextJs /app
folder to find out what we have to do from the Apollo Client and Redux sides to support this.
I'm sorry if some of these thoughts come from "a wrong place of understanding" - RSC are still new to me, and honestly at this point I feel more confused about this than the first time I was learning React. If I'm on a completely wrong track somewhere, please correct me.
Example 1: RTK Query createApi
.
createApi
is a function that is invoked with a description of API endpoints and creates a fully-typed reducer, middleware, selectors - and, if the react
module is active, hooks.
This can for example look like this:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'
// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (builder) => ({
getPokemonByName: builder.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
}),
}),
})
// pokemonApi now has properties like
pokemonApi.reducer
pokemonApi.middleware
pokemonApi.endpoints.getPokemonByName.select
pokemonApi.endpoints.getPokemonByName.initiate
pokemonApi.useGetPokemonByNameQuery
From a RSC perspective, it might already make sense to create a Redux store, add the reducer & middleware, await
the result of the pokemonApi.endpoints.getPokemonByName.initiate
thunk (to use it in a RSC), serialize the cache and rehydrate it on the client later (in case a client component later wants to access that same API endpoint).
From a client perspecive, the same store setup will take place and components will mostly call the useGetPokemonByNameQuery
hook.
Now, it doesn't seem that there is any way that we mark useGetPokemonByNameQuery
as "use client"
, which will prevent any kind of static analysis and early warnings - we will have runtime warnings later when non-server-hooks are called within that useQuery
hook.
An alternative solution would be to tell users to duplicate this code and call createApi
from '@reduxjs/toolkit/query/react'
on the client and '@reduxjs/toolkit/query/react'
on the server.
That doesn't seem feasible, though:
- for one, this would be a lot of code duplication (it's not uncommon for an API to have 50 endpoints, and all of those can be a lot more complex with lifecycle events, etc)
- also, it would make it impossible to use some files in both contexts, as there is always the risk of having both APIs end up on the client
So for now, we will probably just not add "use client"
anywhere until some kind of pattern comes up, but I'm not particularly happy about it.
Example 2: an Apollo "next-specific" helper library.
This one is collapsed since it is not relevant anymore
With classic SSR, for Apollo, we have told people how to create clients for different NextJs render contexts and how to hydrate data from SSR/SSG to the Client.
With RSC, this picture gets a lot more complicated, and we want to create a helper library that's as easy to use as possible.
Problems we intend to solve contain, among others:
- a central point to "register" a
makeClient
function in which the user can "build up" their Apollo Client, with all the individual configuration options. - transport data that was fetched on the server into the client component's Apollo Client instance
- care about making sure that on the server, only one Apollo Client per request is generated and shared between all RSCs for that request
The first approach was to create a registerApolloClient(makeClient: () => ApolloClient)
to register the makeClient
function, paired with a getClient()
function that would lazy-initialize a client and store it differently in different enviroments:
- in classic SSR (which we also want to support), latch onto the NextJs internal
requestAsyncStorage
and create a singleton per-request instance there - in RSC, try to do the same with a
React.cache
call (that, at this point, I hope exists per-request) - on the client side, just hold the Apollo Client in a module-scoped variable
That didn't work. I couldn't find a place in the code to call registerApolloClient
that would actually execute this initialization function both on the server and the client. The whacky workaround would be to tell the user to create a server file and a client file and call registerApolloClient
in both, but tbh., this is something I absolutely want to avoid.
But even that seems unlikely: what if the server just rerenders a subtree? Where would I put registerApolloClient
in that case, to make sure it has been called and getClient()
doesn't try to call an unregistered makeClient
function?
So I changed the design of the function:
const { getClient } = registerApolloClient(function makeClient(){
return new ApolloClient({ uri, new InMemoryCache() })
})
This way, I can make sure that wherever getClient
is called, registerApolloClient
has been executed in the same environment before.
But then we get to the Provider.
At this point, the user has to wrap <ApolloProvider client={getClient()}>
around their application so all client components have access to that. (This assumes that ApolloProvider
is a client component, which is another rabbit hole about bundling that we won't go into at this point.)
But that doesn't work - the client is non-serializable, so it cannot be created in a Server Component.
As a result, we have to tell our users to create a file like
"use client"
export const MyApolloProvider = ({children}) => <ApolloProvider client={getClient}>{children}</ApolloProvider>
and then wrap that MyApolloProvider
component around their application.
As this seems like very annoying boilerplate, my idea was that registerApolloClient
could be extended:
const { getClient, MyApolloProvider } = registerApolloClient(makeClient)
But at that point, if I want to be able to execute registerApolloClient
on the server, the file creating MyApolloProvider
cannot be marked "use client"
. But without that, MyApolloProvider
will not be rendered on the client.
Which leads to the question if it is in any way possible to create but not render client-side components on the server. They would just need to "be in the JSX".
Right now, the workaround is that registerApolloClient
returns a withClient
function that can be used to wrap hooks that should be used on the client in a way that they call getClient
instead of using context at all. But this seems pretty hacky:
import { useQuery as useApolloQuery } from "@apollo/client";
export const { getClient, withClient } = registerApolloClient(makeClient);
export const useQuery = withClient(useApolloQuery);
So yeah, this is pretty open ended, but several questions have popped up during the process of getting into all of this, so I'll try to spell out the obvious ones, and maybe you also spotted other questions within my approaches that I didn't even think of asking.
Basic questions:
- how to have a piece of code that will definitely be executed per request on the server, and once on the client? An equivalent of just writing code in front of
root.render
? - how to create components that are meant to be rendered client-side, but not by importing a file, but by calling a method?
- is there a way of forcing a certain component to be rendered both on the server and on the client?