sannajammeh / trpc-swr

tRPC-ified SWR hooks
https://trpc-swr.vercel.app
MIT License
209 stars 5 forks source link

RFC: New Proxy API & SWR 2.0 #19

Closed sannajammeh closed 1 year ago

sannajammeh commented 1 year ago

New Proxy API & SWR 2.0

This RFC outlines the implementation of tRPC's v10 proxy api in conjunction with SWR hooks, along with some following features:

Motivation

As tRPC v10 is nearing completion (main proxy API is stable), SWR hooks is a "must have" for many developers like myself.

The changes

Below are the changes I propose for trpc-swr (v1.0?).

New proxy API

trpc-swr will take advantage of the newly constructed Proxy technique used in tRPC V10. Examples:


// Given route cats.byId & cats.list
const {data: cat} = trpc.cats.byId.useSWR({id: 1});
const {data: cats} = trpc.cats.list.useSWR();

// Using configurations
const {data: cat} = trpc.cats.byId.useSWR({id: 1}, {revalidateOnFocus: false});

// Directly modifying tRPC client 
const {data: cat} = trpc.cats.byId.useSWR({id: 1}, {
    trpc: { // TRPCRequestOptions
        context: {
            ...op context
        }
    }
});

SWR 2.0

trpc-swr will take advantage of the newest SWR release. This allows for optimisticUpdate support, and other features. With this RFC swr: ^2 will become a peer dependency rather than swr: ^1

Example

const {data: cat, isLoading} = trpc.cats.byId.useSWR({id: 1});

if(isLoading) { // New is loading prop! 
    return <div>Loading...</div>
}

New client creator API

Being able to access the raw tRPC proxy client is incredibly useful during calls outside of React. Previously, the API would've required you to create a new client instance separate from trpc-swr. AKA. Two tRPC instances running. Therefore, I propose a new API for creating a client instance. This will allow for the following:

import { createTRPCClient } from '@trpc/client';
import { createSWRProxyHooks } from 'trpc-swr';

export const client = createTRPCClient<AppRouter>({
    url: '/api/trpc',
    transformer: {
        // ...
    },
});

export const trpc = createSWRProxyHooks(client); // New API

// Use the client instance directly
// Here createSWRProxyHooks internally creates a new TRPCProxy client
// This is for usage outside of React
const cat = await trpc.client.cats.byId.query({id: 1});

// Use the hooks
const {data: cat} = trpc.cats.byId.useSWR({id: 1});

Alternative API 1:

In this API no trpc.client will be available. Instead, you can reference the client directly. This also allows for usage outside of React. Here we are using named properties. trpc will always be the hooks instance, and client will always be the standard tRPC proxy client instance.

import { createSWRProxyHooks } from 'trpc-swr';

// Can also be [trpc, client] = ...
export const {trpc, client} = createSWRProxyHooks({
    url: '/api/trpc',
    transformer: {
        // ...
    },
});

// Use the client instance directly
const cat = client.cats.byId.query({id: 1});

// Use the hooks
const {data: cat} = trpc.cats.byId.useSWR({id: 1});

Alternative API 2:

Keep the raw client inside of a trpc.useContext() call as it is today. Allow passing in a client instance to createSWRProxyHooks to use the same client instance. This would allow for usage outside of React.

Better SSR support

trpc-swr will take advantage of SWR's fallbackData & SWRConfig to provide better SSR support. This requires a new API for createSWRProxyHooks:

.getKey:


// Global fallbackData
<SWRConfig value={{
    fallback: {
        [trpc.cats.byId.getKey({id: 1})]: { // Automatically generated & serialized to match SWR using unstable_serialize.
            id: 1,
            name: 'Mr. Cat',
        },
    }
}} >
{...}
</SWRConfig>

// Local
const {data: cat} = trpc.cats.byId.useSWR({id: 1}, {
    fallbackData: {
        id: 1,
        name: 'Mr. Cat',
    }
});

The reason this is wildy important is for SSR to work from a global state standpoint. Providing the fallback data to a single useSWR hook will not populate the cache for other hooks (Wierdly enough since SWR uses a global cache anyway).

The creation of this .getKey API also gives us a direct way to mutate SWR keys in the global cache. This is useful for optimistic updates, manual cache overwrites, etc.

// We are inside a React component here
const {client} = trpc.useContext(); // Native tRPC client for mutation
const {mutate} = useSWRConfig();

function doSomethingThenMutate(){
    // Do something
    const newCat = client.cats.byId.mutate({id: 1}, {
        id: 1,
        name: 'Mr. Garfield',
    });

    // Mutate the cache
    // All hooks using this key will now have the new data
    await mutate(trpc.cats.byId.getKey({id: 1}), newCat, false);
}

This is something thats been missing even from the current V2 SWR core. Since we use proxies in tRPC this is a very easy API to implement and will provide tremendous DX for SWR users.

Future & possible API's

Prefetch data before React mounts directly into SWR cache

This is a feature SWR is in the process of releasing. It will allow for prefetching data before React mounts. This is useful for SSR, and also for prefetching data before a user navigates to a page. A new API for trpc-swr might look like this:

Potential API 1

Using the .getKey method from above.

// somefile.js
import {preload} from "swr";
import {trpc, client} from "lib/trpc";

// Preload data into SWR cache
// !Note that we are outside of react and therefore using native trpc client to make the request.
const key = trpc.cats.byId.getKey({id: 1});
const fethcer = () => client.cats.byId.query({id: 1})
preload(key, fethcer);

function Cat() {
  // If network is fast then data is available faster
  const { data } = trpc.cats.byId.useSWR({id: 1}); 
  ...
}

Potential API 2

Using a special .preload() on queries.

// somefile.js
import {trpc} from "lib/trpc";

// Preload data into SWR cache
// This internally implements the above API
trpc.cats.byId.preload({id: 1});

// or

trpc.cats.byId.useSWR.preload({id: 1}); // The nature of proxies allows this too.

function Cat() {
  // If network is fast then data is available faster
  const { data } = trpc.cats.byId.useSWR({id: 1}); 
  ...
}

SWR's useSWRMutation API

SWR is also working on a brand new mutation API similar to react-query for triggerable mutations. This is a very exciting API and I'm looking forward to it. I'm not sure how this will work with tRPC yet, but I'm sure we can figure something out.

Footnotes

This is a very rough draft of what I'm thinking. I'm sure there are many things I'm missing. I'm also not sure if this is the best way to do things. I'm open to suggestions and feedback. Additionally, some API's in the current release are not accounted for here. I'll be sure to update this RFC once I have some more time.

Once I have some feedback on the above suggestions I am happy to start a PR for this new API. And huge thanks to @sachinraja for making this wonderful library :)

sachinraja commented 1 year ago

Hey thanks for writing all of this!

New client creator API Being able to access the raw tRPC proxy client is incredibly useful during calls outside of React. Previously, the API would've required you to create a new client instance separate from trpc-swr. AKA. Two tRPC instances running. Therefore, I propose a new API for creating a client instance. This will allow for the following:


import { createTRPCClient } from '@trpc/client';
import { createSWRProxyHooks } from 'trpc-swr';

export const client = createTRPCClient({ url: '/api/trpc', transformer: { // ... }, });

export const trpc = createSWRProxyHooks(client); // New API

// Use the client instance directly // Here createSWRProxyHooks internally creates a new TRPCProxy client // This is for usage outside of React const cat = await trpc.client.cats.byId.query({id: 1});

// Use the hooks const {data: cat} = trpc.cats.byId.useSWR({id: 1});

This is nice, but we can't do this because it breaks SSR. With this code example, `client` will be shared across all requests. Alternative API 2 seems like a good way to go here.

I like `.getKey`, but I think we should try to reduce usage of it as much as possible. For this specific example:
> The creation of this .getKey API also gives us a direct way to mutate SWR keys in the global cache. This is useful for optimistic updates, manual cache overwrites, etc.
```ts
// We are inside a React component here
const {client} = trpc.useContext(); // Native tRPC client for mutation
const {mutate} = useSWRConfig();

function doSomethingThenMutate(){
    // Do something
    const newCat = client.cats.byId.mutate({id: 1}, {
        id: 1,
        name: 'Mr. Garfield',
    });

    // Mutate the cache
    // All hooks using this key will now have the new data
    await mutate(trpc.cats.byId.getKey({id: 1}), newCat, false);
}

I would still prefer if we had some way of using the proxy API here instead.

Prefetch data before React mounts directly into SWR cache

Again, instead of .getKey here I think trpc.cats.byId.preload would be better. So I prefer Potential API 2.

This also matches the @trpc/react API much better.

SWR's useSWRMutation API

Yes, would love to have support this. The API will probably work the same as useSWR.

Again, thanks for outlining this! I don't have much info on what is idiomatic SWR as I don't use it that much myself, so your help is much appreciated.

sannajammeh commented 1 year ago

Got it! Getting started as I have a bit more time on my hands :)

sannajammeh commented 1 year ago

While building this I've met a slight problem. Essentially what we want is to only use a single TRPCClient instance, however, we cannot follow the API used by @trpc/react where you pass the client into the Context provider due to the fact SWRHook.preload() is supposed to kick off a request outside of React before the app mounts. Therefore the client must be available before React hydrates.

These are not alternatives, both can be used. I overloaded the hooks creator function to accept an optional client like so:

// Option 1
export const trpc = createSWRProxyHooks<AppRouter>(config); // Similar to current API, generates the client

// Option 2 - If you need the native client for whatever reason
const client = createTRPCClient<AppRouter>(config); // From @trpc/client

export const trpc = createSWRProxyHooks(
  null,
  client
); // Will use the provided client.

// Use the native client proxy for whatever reason (custom requests, custom mutations etc.), example: Init the @trpc/client with this
export const trpcNative = createTRPCClientProxy(client); // imported from @trpc/swr

Both of the options currently only use a single instance of the native TRPCClient. We do have the option of leveraging trpc.createClient() and injecting it through context, however it would require some modifications so .preload() has access to a global instance of this as long as its called top level before any .preload() calls.

We can also only leave option 1 on the table and provide a trpc.getClient() as opposed to trpc.createClient(). This will be the same instance as you would get through useContext()

sachinraja commented 1 year ago

What's the difference between that and prefetching in the top level App component?

sannajammeh commented 1 year ago

No effects will run before the App is mounted & hydrated, in the case of SSR. For large apps this can be quite a bit. Secondly preload() can be used to for instance kick off a request to trpc.users.byId when a user hovers a link. This is of course inside of react, but it cannot be one as a hook due to the fact that SWR's preload api is also not inside of react.

We can build a custom hook, but this ends up not reflecting how SWR's preload API works.

If we are making it compatible with SWR v2 either some global reference of the client (when trpc.createClient() is called) or my proposed API should be used.

I did come up with another method however. Let me know what you think :)

// Option 1
export const trpc = createSWRProxyHooks<AppRouter>(config); // Similar to current API, generates the client

// Option 2 - If you need the native client for whatever reason
const client = createTRPCClient<AppRouter>(config); // From @trpc/client

export const trpc = createSWRProxyHooks.withClient(client);
sannajammeh commented 1 year ago

I do see what you mean by the SSR sharing of the client (in standard nodejs ssr not lambdas). This could be a problem. In that case I propose a different solution:

Preload keeps its own trpc client

We keep the API as similar to @trpc/react meaning the client will be injected through context.

createSWRProxyHooks will only accept config which will be passed down.

// Client creator API
// lib/trpc.ts
export const trpc = createSWRProxyHooks<AppRouter>({
    links: [
        httpBatchLink({
            url: getApiUrl(),
        }),
    ]
})

// In _app.tsx
function MyApp({ Component, pageProps }) {
  const [client] = useState(() => trpc.createClient()); // Notice no config here as its provided when creating the hooks

  return (
    <trpc.Provider client={client} >
      <Component {...pageProps} />
    </trpc.Provider>
  )
}

Minimal implementation:

// As preload is a browser only function, request sharing will not matter on SSR. 
let client: TRPCClient<AnyRouter>; 
const preload = (pathAndInput, ...args) => {
   if(typeof window === "undefined") return Promise.resolve();

    return _SWRPreload(pathAndInput, () => {
        if(!client) {
            client = createClient();
        }
        return client.query(...pathAndInput, ...args);
    });
}

With this approach we can keep the API as similar as possible to the react one. The client is not shared accross SSR requests and preload is a noop on SSR.

Once the trpc.Provider component mounts it will directly set the client variable (can be global cache too). This means preload() will not have to create another client if its used to preload data on hover or something similar.

If however, preload is used before React, it will create its own client and dispatch the request. trpc.Provider will take over once app mounts and the old client will be garbage collected. We can probably use refs to manually clear it from memory as well.

Update

You are right. This API which would be alternative API 2 is the best path imo.

I've updated my fork here: https://github.com/sannajammeh/trpc-swr useSWR, useSWRMutation, preload and getKey all function with 100% test coverage.

I will get started on SWRInfinite, however as this is a codesplit package, how do you propose we use it? Similar to the current API?

export const infinite = createSWRInfiniteProxy(trpc)

infinite.cats.useSWRInfinite(...args)
// or since infinite is already presumed
infinite.cats.use(...args)
sachinraja commented 1 year ago

Sorry for the late response. I'm a bit confused, which API did you decide to go with for preload?

I will get started on SWRInfinite, however as this is a codesplit package, how do you propose we use it? Similar to the current API?

I like infinite.cats.use(...args).

sannajammeh commented 1 year ago

I went with tRPC/react's api with a few modifications.

  1. You create the hooks with the tRPC config
  2. You call createClient and inject in the provider without config
  3. Preload will use its own client until react hydrates then use the context provided client.
  4. Preloads client will be Garbage collected

The only difference between this and tRPC/react'a api is that the config has to go inside the createHooks method and not create client. It's a necessary compromise to make preload work.

This also lets us build a SSR utility which I'll propose a bit later.

sannajammeh commented 1 year ago

Update:

useSWRInfinite has been successfully implemented. All that remains now is typing the non-proxy hooks as we also export their methods.

97.7% Test coverage. Some quirks on infinite I have to iron out.

New infinite API's .use() & .useCursor()

react-query contains custom logic to use infinite queries. SWR lets this the user decide if they want to use cursors, pagination or any other kind of use case. To be more compliant with @trpc/react we now have two methods.

infinite.<endpoint>.use() & infinite.<endpoint>.useCursor(). The .use api is a tRPC ified version of SWR. The paging logic must be implemented by hand which can cover almost all use cases. useCursor()'s API is made to mimmic @trpc/react. Its a helper function providing the cursor paging logic out of the box, makes it really easy to integrate with prisma on the back-end like useInfiniteQuery does.

infinite.<endpoint>.use()

Manual integration of the paging. Any kind of logic can be used here. ID fetching, cursor etc.

const infinite = createSWRInfiniteProxy(trpc)

    const Component = () => {
        const { data, setSize, size } = infinite.people.get.use((index, previousPageData) => {
            if (index !== 0 && !previousPageData?.length) return null // Last page
            return { limit: 1, page: index }
        })

        if (!data) {
            return <div>Loading...</div>
        }

        const people = data.flatMap((page) => page)

        const hasMore = (data.at(-1) ?? []).length > 0

        return (
            <>
                <div>
                    {people.map((user, index) => {
                        return <p key={user.name}>{user.name}</p>
                    })}
                </div>

                {hasMore && <button onClick={() => setSize(size + 1)}>Load More</button>}
            </>
        )
    }

infinite.<endpoint>.useCursor()

Prebuilt cursor logic

const infinite = createSWRInfiniteProxy(trpc)

    const Component = () => {
        const { data, setSize, size } = infinite.people.get.useCursor({limit: 5}, 
                         (data) => data?.nextCursor
                 )

        if (!data) {
            return <div>Loading...</div>
        }

        const people = data.flatMap((page) => page.items)
        const hasMore = !!data.at(-1)?.nextCursor

        return (
            <>
                <div>
                    {people.map((user, index) => {
                        return <p key={user.name}>{user.name}</p>
                    })}
                </div>

                {hasMore && <button onClick={() => setSize(size + 1)}>Load More</button>}
            </>
        )
    }

Both these are now active in my fork and on @chiefkoshi/trpc-swr@1.0.0-beta-5 on npmjs. I'll upgrade the latest trpc core packages to the newest beta later.

sachinraja commented 1 year ago

Thanks for the update! That looks awesome

sannajammeh commented 1 year ago

Update

Was waiting for tRPC v10 to release and got caught with work. I have some time on my hands to finalize this RFC.

New /next & /ssg entry points with SSG/SSR helpers

We now export Next.js helpers, this includes a withTRPCSWR HOC, and a proxy helper function using server side calls. The flow is as follows:

  1. trpc proxy ssr helper is initialized in server context (inits router.createCaller()
  2. trpc..fetch() is called. This can be awaited (for users wanting sync behavior) or not .dehydrate() will await either way.

As we are using SWR, there is no point in making a fetch call, thus we use the caller to make this a server to server call. The key is then serialized using SWR's serialization function.

  1. trpc.dehydrate() is called. This will serialize the data using any transformer provided or just return it. Same behavior as @trpc/next

Usage

In any server util file

// server/ssg

export const createSSG = () => {
    return createProxySSGHelpers({
        router: appRouter,
        ctx: {},
        transformer: SuperJSON,
    })
}

In next _app

// _app.tsx
const App = ({ Component, pageProps }: AppProps) => {
    const [client] = useState(() => trpc.createClient())
    return (
        <trpc.Provider client={client}>
            <Component {...pageProps} />
        </trpc.Provider>
    )
}

export default withTRPCSWR({
    transformer: SuperJSON,
})(App)

In any SSG/SSR route

export const getStaticProps: GetStaticProps = async () => {
    const trpc = createSSG()

    trpc.user.byId.fetch({ id: 1 })
        trpc.user.byId.fetch({ id: 2 })  
       // Can decide to await or not. This is useful if the user wants parallell execution for faster builds/ssr

    return {
        props: {
            swr: await trpc.dehydrate(), // Must be awaited
        },
    }
}

And thats it, the hooks will have hydrated state.

Online documentation

I'm currently working on documenting everything in this RFC as an online documentation using Nextra as the static generator.

@sachinraja how does this look? We can most likely make something similar to @trpc/next when you init the trpc client as well.

r3nanp commented 1 year ago

@sannajammeh Amazing! This RFC looks awesome.

sachinraja commented 1 year ago

That looks great @sannajammeh!

Would you be interested in taking over development of trpc-swr? No pressure, but I don't have the bandwidth to manage this project on top of my other ones and I myself don't use SWR. I'd be happy to transfer the GitHub repo and/or the npm package if you're interested. Thanks!

sannajammeh commented 1 year ago

That looks great @sannajammeh!

Would you be interested in taking over development of trpc-swr? No pressure, but I don't have the bandwidth to manage this project on top of my other ones and I myself don't use SWR. I'd be happy to transfer the GitHub repo and/or the npm package if you're interested. Thanks!

I definitely don't mind taking over maintenance of trpc-swr! Would you need the npmjs username or email?

sachinraja commented 1 year ago

Thank you so much! I just need your username to transfer ownership.

sannajammeh commented 1 year ago

Thank you so much! I just need your username to transfer ownership.

Username is: chiefkoshi

sachinraja commented 1 year ago

Invited you!

sannajammeh commented 1 year ago

Invited you!

I've accepted the npmjs code. Would you mind transferring the github repo as well?

sachinraja commented 1 year ago

Can't transfer the repo since your fork has the same name: https://github.com/sannajammeh/trpc-swr

sannajammeh commented 1 year ago

Makes sense. Renamed it!

sachinraja commented 1 year ago

I think you might actually have to delete your fork according to these docs? In any case I've invited you as a collaborator so feel free to push your branches here.

sannajammeh commented 1 year ago

Hello @sachinraja

I've publish the first release candidate of this package on npm. My fork is now deleted and changes have been pushed here.

Would you mind transferring the repo over to me so I can deploy the documentation on Vercel?

sannajammeh commented 1 year ago

@sachinraja Any possibility for this?

sachinraja commented 1 year ago

@sannajammeh I think I sent you an invite to transfer the repo a day after you asked

sachinraja commented 1 year ago
CleanShot 2023-02-11 at 08 41 23@2x

@sannajammeh

sannajammeh commented 1 year ago
CleanShot 2023-02-11 at 08 41 23@2x

@sannajammeh

We may have to abort and retry the transfer. It appears GitHub wont fulfill this one

sachinraja commented 1 year ago

Done, sent another transfer request.

r3nanp commented 1 year ago

@sannajammeh Any updates about this RFC?

sannajammeh commented 1 year ago

@sannajammeh Any updates about this RFC?

It's currently in RC mode! I'm testing it in my dev environments and trying to collect any bugs before I release the V1.

https://trpc-swr.vercel.app

There's also a bug in the Next.js rust compiler preventing SWR use in the new App directory. https://github.com/vercel/swr/issues/2632

As a hot fix we had to duplicate SWR's hashing function, which isn't ideal. I'm confident we can release the v1 once this is fixed.

sannajammeh commented 1 year ago

Hi everyone!

I've successfully tested this library in most environments now. All thats missing is a an E2E test with Next 13.

v1 will release by the end of this week :)

sannajammeh commented 1 year ago

Update

Hit some weird dependency resolution issues with both TypeScript and Next 13. For some reason the very same resolution method works in SWR, but not here. If I'm unable to resolve this by tomorrow, I'll be switching to a more safe packaging method instead, similar to tRPC.

Example:

Essentially the problem is as follows:

When testing inside of the monorepo, internal packages of trpc-swr like /infinite cannot resolve the trpc-swr core package. I am using the exact same technique as SWR itself, yet the resolution is somehow different.

This only appears to happen in the monorepo and not external Next.js projects.

Edit Apr 4.

Looks like pnpm@8.1 has resolved this issue and will respect the files when installing packages. Will be confirming if this fixes the issue by tomorrow and pushing a subsequent release if resolved.

Edit Apr 11.

After a week of testing it appears that the issue remains and is due to Webpack's handling of symlinks, causing Next to import ESM swr and trpc-swr always importing CJS swr despite being in ESM itself. We are unable to solve this for now. In order to ensure safety with E2E tests, I will switching over to the new package strategy detailed above. This gives us a number of benefits:

Once merged the trpc-swr will be archived at its current version and the above packages will be preferred.

sannajammeh commented 1 year ago

All updates will now arrive in vercel/next.js#40

shunkakinoki commented 1 year ago

Thank you for your work!