Skip to content

RSC: How to realize shared code with client-specific execution resultsΒ #26460

@phryneas

Description

@phryneas

@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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Resolution: StaleAutomatically closed due to inactivityStatus: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions