Server Rendering Contexthttps://linear.app/seedhypermedia/issue/SHM-1984/server-render-minor-issues

    Current Pattern

      Currently, the server rendering is implemented in a very messy way, and in many cases it is just broken because we opted to simplify the code.

      When the client mutates a Resource that was server-rendered

        todo: describe the situation on the web profile page when the name and photo is mutated.

      supportQueries and supportDocuments

        todo: describe this mess

    Proposed Solution

      In the server render, we will explicitly decide what data to load, and pre-fill a ReactQuery cache. Every page will serialize the cache and pre-fill it in the same way.

      Now, the components inside the page can simply call the RQ hooks in the same way every time. In the server it will use the pre-filled cache. On the client's first render, it will use the cache that was passed down from the server

      Benefit

        This means our components can be much simpler, because we will not need to explicitly hand down the server data through props and use some conflict resolution to decide when to use that compared to the RQ hooks. The code will be shorter, more consistent with desktop data loading code, and less buggy.

        If the server does not preload something, the client will load the data async using RQ when it wakes up.

        If you decide that some more data should be loaded on the server, you can just add it to the server cache fill, without changing any client components.

        This will avoid the confusing "supportQueries" and "supportDocuments" code which is very unpleasant to work with and has caused a lot of confusion on our team. This code was initially added to support server rendering, and the proposed pattern accomplishes the same thing in a less confusing way.

    Specifics - from ChatGPT

      Core idea

        On the server (loader)

          Create a QueryClient.

          prefetchQuery(or prefetchInfiniteQuery) for whatever you want ready.

          dehydrate(queryClient) and return that in the loader data.

        In the route component (both server + client)

          Read dehydratedState from useLoaderData.

          Wrap your UI in <QueryClientProvider> + <Hydrate state={dehydratedState}>.

          Inside that, use normal useQuery hooks.

          On the server render, React Query uses the prefetched cache.

          On the first client render, React Query reuses that cache instead of refetching.

      That’s the pattern.

      Minimal example in Remix

      1. Root setup

      app/root.tsx

      import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
      import { json } from "@remix-run/node";
      import {
        Links,
        Meta,
        Outlet,
        Scripts,
        ScrollRestoration,
        useLoaderData,
      } from "@remix-run/react";
      import {
        QueryClient,
        QueryClientProvider,
      } from "@tanstack/react-query";
      import { Hydrate, dehydrate } from "@tanstack/react-query";
      import { useState } from "react";
      import { getUser } from "./models/user.server"; // whatever
      
      export async function loader(_args: LoaderFunctionArgs) {
        const queryClient = new QueryClient();
      
        // Preload whatever your route tree needs
        await queryClient.prefetchQuery({
          queryKey: ["user"],
          queryFn: () => getUser(),
        });
      
        return json({
          dehydratedState: dehydrate(queryClient),
        });
      }
      
      export default function App() {
        const { dehydratedState } = useLoaderData<typeof loader>();
      
        // important: create one client on the client, not on every render
        const [queryClient] = useState(
          () =>
            new QueryClient({
              defaultOptions: {
                queries: { refetchOnWindowFocus: false },
              },
            })
        );
      
        return (
          <html lang="en">
            <head>
              <Meta />
              <Links />
            </head>
            <body>
              <QueryClientProvider client={queryClient}>
                <Hydrate state={dehydratedState}>
                  <Outlet />
                </Hydrate>
                <ScrollRestoration />
                <Scripts />
              </QueryClientProvider>
            </body>
          </html>
        );
      }
      

      The key pieces for the pattern are:

        Loader creates QueryClient, prefetches, then dehydrate.

        Component re-creates a client (client-side stable via useState) and hydrates with loader data.

      You can have more prefetching in child route loaders too, you just need to:

        Return their dehydratedState fragments.

        Merge them into a single dehydratedState in root (or have root loader do all prefetching centrally).

      But simplest thing that actually works: root loader does the prefetch for the stuff you care about on first paint.

      2. Normal useQuery in a route

      app/routes/_index.tsx

      import { useQuery } from "@tanstack/react-query";
      import { getUser } from "~/models/user.server"; // same fn, but don't import server fn in browser in real code; wrap in API call or isomorphic wrapper
      
      async function fetchUser() {
        // in practice you'd hit an endpoint, not call a server fn directly
        return getUser();
      }
      
      export default function Index() {
        const { data, isLoading } = useQuery({
          queryKey: ["user"],
          queryFn: fetchUser,
        });
      
        if (isLoading) return <div>Loading…</div>;
      
        return <div>Hi {data.name}</div>;
      }
      

      Because the query key + fn match the prefetched one, the first server render and first client render both pull from the same hydrated cache. Client does the normal React Query lifecycle after that.

      Subtle bits you’ll trip on if you’re not careful

        Do not create a fresh QueryClient on every render of App. You already know this, but that’s why you see the useState(() => new QueryClient()) trick.

        For streaming / defer, you can’t just blindly use dehydrate once; you’d move some data to Remix’s data streams and possibly stop using React Query for that part.

        If you start doing route-level prefetching, you either:

          centralize prefetch in root loader (route loaders call into it), or

          have each loader return its own dehydratedState and merge in root (annoying, but doable).

      But the “pattern” you’re asking for is exactly: loader prefetch → dehydrate → Hydrate + useQuery.