t3-oss / create-t3-turbo

Clean and simple starter repo using the T3 Stack along with Expo React Native

Home Page:https://turbo.t3.gg

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

feat: Hydrate Client Component (QueryClient) state w/ dehydrate in RSC

dihmeetree opened this issue · comments

Describe the feature you'd like to request

Instead of running tRPC queries directly in the RSC and passing it's result to the client component as props, we should prefetch the data and hydrate QueryClient data to the client components instead.

Right now data is fetched in the RSC like so:

const posts = api.post.all();

This data is passed to the client component via props, however the query is then run again here

const { data: posts } = api.post.all.useQuery(undefined, {
initialData,
});

Note: Using initialData isn't actually what we want. This adds data to the cache, only if something doesn't already exist. So if you added another page and you navigated between them... if your data changes (in the background), the data loaded via the RSC won't change (as it's old data is still cached via initialData).

Right now data is fetched in the RSC (passed to the client via props), and then again on the client (double query.. which isn't what we want).

Instead, we should hydrate the QueryClient via the RSC. This guarantees that fresh data will always be available to the client component.

Describe the solution you'd like to see

I've got something like this working in my project

import { Suspense } from "react";
import { appRouter } from "@acme/api";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { createServerSideHelpers } from "@trpc/react-query/server";
import superjson from "superjson";

import { createContext } from "~/trpc/server";
import { CreatePostForm, PostList } from "./components/posts";
import Spinner from "./components/spinner";

async function Posts() {
  const api = createServerSideHelpers({
    router: appRouter,
    ctx: await createContext(),
    transformer: superjson,
  });
  await api.post.all.prefetch();
  const dehydratedState = dehydrate(api.queryClient);

  return (
    <HydrationBoundary state={dehydratedState}>
      <PostList />
    </HydrationBoundary>
  );
}

export default async function HomePage() {
  return (
    <>
      <CreatePostForm />
      <Suspense fallback={<Spinner />}>
        <div className="w-full max-w-2xl overflow-y-scroll">
          <Posts />
        </div>
      </Suspense>
    </>
  );
}

The PostList component can then be rendered like so

export function PostList() {
  const [posts] = api.post.all.useSuspenseQuery();

  if (posts.length === 0) {
    return (
      <div className="relative flex w-full flex-col gap-4">
        <PostCardSkeleton pulse={false} />
        <PostCardSkeleton pulse={false} />
        <PostCardSkeleton pulse={false} />
        <div className="absolute inset-0 flex flex-col items-center justify-center bg-black/10">
          <p className="text-2xl font-bold text-white">No posts yet</p>
        </div>
      </div>
    );
  }

  return (
    <div className="flex w-full flex-col gap-4">
      {posts.map((p) => {
        return <PostCard key={p.id} post={p} />;
      })}
    </div>
  );
}

Also it should be noted that refetchOnMount is set to false so queries are never run on mount. Remember.. live data is being hydrated in the RSC.. so there's no reason to re-run the queries on the client side.

export const queryClientOptions = {
  defaultOptions: {
    queries: {
      refetchOnMount: false,
    },
  },
};

// trpc/client.tsx
const [queryClient] = useState(() => new QueryClient(queryClientOptions));

👍🏻

Additional information

No response

I want to understand something: this will achieve exactly the same behavior, but won't require us pass down props to the other component. Yes? Or does it change something about behavior? Right now in my project I am not using dehydration or hydration boundaries at all, and am doing it it with initialData. Want to really understand if these two approaches are different. And if so, how

I want to understand something: this will achieve exactly the same behavior, but won't require us pass down props to the other component. Yes? Or does it change something about behavior? Right now in my project I am not using dehydration or hydration boundaries at all, and am doing it it with initialData. Want to really understand if these two approaches are different. And if so, how

I'm still investigating this issue, and I don't have a comprehensive answer yet.

The initial assumption I made in my original post no longer holds true after thorough testing. Instead of relying solely on hydrating the QueryClient via the RSC (Resource Server Component), it seems that enabling PPR (Partial Prerendering) in Next.js is what enables RSCs to re-render on navigation, thus making hydration beneficial. I need to delve deeper into why this behavior occurs and assess whether it's desirable or not—whether it's a bug or intended functionality.

For further reference, you can explore the repository at this link, which demonstrates the hydration process: https://github.com/dihmeetree/trpc-appdir-issue-demo

Despite this, I still advocate for hydrating the tRPC queries in the RSCs. Doing so ensures that the client component consistently receives up-to-date data, unlike when relying solely on initialData, which will not update. Additionally, hydration allows us to disable refetches after mount on the client. Given that we're supplying live data, there's no need to re-fetch the same query client-side.

I think this is true, about stale data. I just did something... Take a look at my setup:

I have two components here:
image
The button is a client component and the info above is a RSC.
The client component (button), has the initialData that comes from the RSC:

const query = api.app.kodixCare.getCurrentShift.useQuery(undefined, {
    enabled: false,
    initialData: currentShift,
  });

The user can interact with this CC Button, and it mutates data via a server action. This server action uses revalidatePath() after it mutates currentShift data. Let's see the new components after we click this button.

CLICK
image
RELOAD PAGE
image

So, does it mean that using this dehydratio/hydration boundary and useSuspenseQuery will make this kind of problem not happen when I revalidatePath()?

I am so confused as to how to build these interfaces

Using HydrationBoundary gives a way better DX

  • Already created client components don't need to add a "initialData" props to get data from the server
  • You can just encapsulate already existing components that use react query into a Server Component that prefetches the queries, this is a huuuge improvement towards moving components from fetching on client side to fetching on server side
  • From my experience, fetching data from server side and passing as initialData get's staled data, as @dihmeetree stated, this is requiring us to maintain "refetchOnMount", otherwise data updates don't get reflected on the UI

Made a POC of some hydration helpers in #877, maybe something like that would be nice?

Tried out your updates @juliusmarminge, while it looks good on the prefetching side, but data doesn't seem to be updated between navigation still. Is that something that is till WIP?

Also what's the purpose of this

staleTime: 60 * 1000,

Theoretically refetchOnMount: false should be set in the QueryClient options, as there is no need to refetch queries from the client side, as we're already getting fresh data from the queries being hydrated in the RSC.