Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
phryneas opened this issue Mar 22, 2023 · 5 comments
Closed
Labels
Resolution: Stale Automatically closed due to inactivity Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug

Comments

@phryneas
Copy link
Contributor

phryneas commented Mar 22, 2023

@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?
@sebmarkbage
Copy link
Collaborator

For the RTK Query example the idiomatic way would to be to fork the createQuery implementation into a “react-server” specific implementation.

exports: {
  “query/react”: {
    “react-server”: “./query/react-server.js”,
    “default”: “./query/react.js”
  }
}

The import and api can work the same way in both environments from the consumers perspective.

When calling the react-server version of createApi it can generate a stub version of the Hook which throws a helpful error when used in Server Components or just exclude it all together.

The principle that underlines this is static optimization of various combinations. In the ecosystem we shouldn’t have to boot code that includes all possible runtime branches. We will also move to use export conditions for production/development for example to support ESM for similar reasons.

This goes both ways that the client shouldn’t have to download server specific things for the branches that uses server-only things but the server also shouldn’t have to boot a bunch of code that isn’t relevant on the server.

So that’s why it’s worth having two separate implementations/entry points to the api that can each be optimal.

Yes, it’s a burden to library authors to optimize code but it’s a one time thing that helps the end user at the end of the day.

@sebmarkbage
Copy link
Collaborator

For the Apollo section 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?

const getSingleton = React.cache(() => {
  return new PerRequestThing();
});

This won’t necessarily give you a single one on the client since the client cache can be refreshed but that might actually be a feature you want. For other cases you can use a module level variable only on the client but it really depends on how you’re getting this because it has to be called inside React render for it to be contextual to a request.

Not sure how you’re thinking about the rest of the api.

how to create components that are meant to be rendered client-side, but not by importing a file, but by calling a method?

If the component is meant to execute during SSR and then hydrate, it’s a client component and it works the same. As long as it’s used in a client component scope.

It doesn’t work to have a library generate a client component from a server component on the fly thought. The code for them must be built by the bundler and we have to know to be build them before it happens. So this doesn’t really work in the general sense without the user placing its code somewhere behind a “use client” in the bundler graph.

is there a way of forcing a certain component to be rendered both on the server and on the client?

Not sure what this means. Is it referring to “SSR” then it’s just a client component.

@phryneas
Copy link
Contributor Author

phryneas commented Jun 16, 2023

@sebmarkbage thanks for the Apollo-part answers, but I think we can ignore that part, which is why I collapsed it :)
The "Apollo" part has already been answered in depth and we have a RSC-ready Apollo Client companion package over here: https://github.com/apollographql/apollo-client-nextjs (also see the related RFC)

Copy link

github-actions bot commented Apr 9, 2024

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

@github-actions github-actions bot added the Resolution: Stale Automatically closed due to inactivity label Apr 9, 2024
Copy link

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!

@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Apr 17, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Resolution: Stale Automatically closed due to inactivity Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug
Projects
None yet
Development

No branches or pull requests

2 participants