Two Ways of Composing Server Components in TanStack
When introducing RSC the post "React Server Components Your Way" π states:
use client still works the same way in TanStack Start when the server intentionally wants to render a client component.
Also, in his blog "Who Owns the Tree? RSC as a Protocol, Not an Architecture" π Tanner Linsley wrote:
The standard RSC model assumes server-owned trees, so the primitives are designed around that direction.
use client, hydration boundaries, streaming, suspense fences, manifest-driven reference resolution all assume the server is composing and the client is receiving. That's fine for what those frameworks were built for and TanStack Start supports 'use client' exactly the same.
What did Tanner mean by "exactly the same"? In TanStack's guide on Server Components π there is no reference on the use client.
The following examples try to illustrate what the "exactly the same" means by applying two ways of composing RSC.
The working project can be found at Github π. Along with composition it contains other examples originally introduced in TanStack's RSC guide π.
1. The Next Way
A client component CopyButton interacts with browser API to write text to clipboard. It is marked with use client directive.
// src/components/CopyButton.tsx
"use client";
export function CopyButton({ textToCopy }: { textToCopy: string }) {
return (
<button
className="text-white bg-blue-500 hover:bg-blue-400 p-4 rounded-full"
onClick={async () => {
await navigator.clipboard?.writeText(textToCopy);
}}
>
{`Copy ${textToCopy.length} bytes`}
</button>
);
}The Code is a server component. It allows to import client code marked with use client. The value of property text we get from the server by reading a file. To ensure it executes only on server we render the component inside a server function getCode.
// src/components/getCodeComposite2.tsx
import { createServerFn } from "@tanstack/react-start";
import { renderServerComponent } from "@tanstack/react-start/rsc";
import { readFile } from "node:fs/promises";
import z from "zod";
import { CopyButton } from "./CopyButton";
function Code({ text }: { text: string }) {
return (
<div className="relative group" >
<div className="absolute right-4 top-4">
<CopyButton textToCopy={text} />
</div>
<pre className="p-4 bg-slate-600 text-yellow-300 rounded">
{text}
</pre>
</div>
);
}
export const getCode = createServerFn()
.inputValidator(z.string().default("package.json"))
.handler(async ({ data }) => {
const content = (await readFile(data)).toString();
const src = await renderServerComponent(<Code text={content} />);
return { src };
});This is the router /composite2 where the RSC got received from the stream, hydrated and rendered. It's also SSR'ed on the initial rendering.
// src/routes/composite2.tsx
import { createFileRoute } from "@tanstack/react-router";
import { CompositeComponent } from "@tanstack/react-start/rsc";
import { getCode } from "~/components/getCodeComposite2";
export const Route = createFileRoute("/composite2")({
loader: async () => {
const { src } = await getCode({ data: "src/components/getCodeComposite2.tsx" });
return { src };
},
component: RouteComponent,
});
function RouteComponent() {
const { src } = Route.useLoaderData();
return (
<div className="p-2">
<h3>COMPOSITE 2</h3>
<>{src}</>
</div>
);
}The directive use client really matters in this example. If we remove it from the CopyButton.tsx we got the error:
Event handlers cannot be passed to Client Component props.
<button className=... onClick={function onClick} children=...>
^^^^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
2. The TanStack Way
The composite server component represent a "layout" in which slots we "render props" (interactive functions). We create the server layout by the createCompositeComponent high-order function.
// src/components/getCodeComposite.tsx
import { createServerFn } from "@tanstack/react-start";
import { createCompositeComponent } from "@tanstack/react-start/rsc";
import { readFile } from "node:fs/promises";
import z from "zod";
interface ComompositeLayoutProps {
copyButton: (data: { textToCopy: string }) => React.ReactNode;
}
export const getCode = createServerFn()
.inputValidator(z.string())
.handler(async ({ data }) => {
const content = (await readFile(data)).toString();
const src = await createCompositeComponent((props: ComompositeLayoutProps) => (
<div className="relative group">
<div className="absolute right-4 top-4">
{props.copyButton({ textToCopy: content })}
</div>
<pre className="p-4 bg-slate-600 text-white rounded">
{content}
</pre>
</div>
));
return { src };
});This is the router /compsite where the RSC source got received from the stream. During rendering we provide the actual interactive function to the render prop copyButton by the CompositeComponent API.
// src/routes/composite.tsx
import { createFileRoute } from "@tanstack/react-router";
import { CompositeComponent } from "@tanstack/react-start/rsc";
import { getCode } from "~/components/getCodeComposite";
export const Route = createFileRoute("/composite")({
loader: async () => {
const { src } = await getCode({ data: "src/components/getCodeComposite.tsx" });
return { src };
},
component: RouteComponent,
});
function RouteComponent() {
const { src } = Route.useLoaderData();
return (
<div className="p-2">
<h3>COMPOSITE</h3>
<CompositeComponent
src={src}
copyButton={({ textToCopy }) => (
<button
className="text-white bg-blue-500 hover:bg-blue-400 p-4 rounded-full"
onClick={async () => {
await navigator.clipboard?.writeText(textToCopy);
}}
>
{`Copy ${textToCopy.length} bytes`}
</button>
)}
/>
</div>
);
}3. Breakdown
(provided with the assistance of Gemini)
This is a set of examples that demystifies the "Two Worlds" of TanStack Start. By comparing these side-by-side, we've exposed exactly how Tanner Linsleyβs "Server-Owned" claim coexists with his "Client-Owned" philosophy.
Explanation of The Next Way
This is the "Exactly the Same" implementation. Even though we are using a TanStack Server Function (getCode), we are invoking the Standard RSC Protocol.
- The Mechanism:
renderServerComponenttriggers the React "Flight" server-renderer. - The Serialization: When the renderer hits
CopyButton, the bundler sees theuse clientdirective. It doesn't execute the button code; it serializes a Client Reference. - The Error: The experiment β removing
use clientand getting theEvent handlers cannot be passederror - is the ultimate proof. It proves that TanStack Start is running a strict RSC environment that enforces the boundary between server code and client interactivity. - Ownership: The Server "owns" the tree. The structure of the
Codecomponent is decided on the server and streamed to the client as a layout.
Explanation of The TanStack Way
This is the "Client-Owned" (Composite) implementation. This is where TanStack departs from the Next.js model.
- The Mechanism:
createCompositeComponentdoesn't just render a tree; it creates a Contract. - Inversion of Control: In
getCodeComposite.tsx, the server defines a "slot" (thecopyButtonprop) but cannot fill it. It says: "I'll provide the layout, but the Client must provide the implementation for the button." - Flexibility: Note that in the
composite.tsxroute, we defined theonClickhandler directly in the route component. There is no need for a separate file with ause clientdirective because the button was never "on the server" to begin with. - Ownership: The Client "owns" the tree. It fetches a server-side layout and injects its own interactive pieces into it.
Summary Comparison Table
| Feature | /composite2 (The "Same" Way) |
/composite (The "TanStack" Way) |
|---|---|---|
| Directive | Requires "use client" on the leaf. |
No directive needed for the injected part. |
| Boundary | Defined by file-level metadata. | Defined by the props of the composite. |
| Composition | Server(Client) |
Client(Server(Client)) |
| Hydration | Standard React Flight hydration. | TanStack CompositeComponent reconstruction. |
| Mental Model | "The Server sends me a finished UI." | "The Server sends me a template with holes." |
Why this matters
Our code confirms that TanStack Start is an RSC Super-set.
- It provides the infrastructure for standard RSC (so we can apply the
use clientdirective). - It provides the abstraction (
createCompositeComponent) to solve the "Client-Owned" problem that standard RSC struggles with (like passing complex client state or functions back into a server-generated tree).