Tanner Linsley —《谁拥有这棵树?RSC 是一种协议,而不是一种架构》(全文)
原文: https://tanstack.com/blog/who-owns-the-tree 作者: Tanner Linsley(TanStack 创始人) — 2026-04-28 本译版定位: 完整逐段翻译 + 译注。技术术语保留英文(RSC / Server Components /
'use client'/ Composite Components / TanStack Start / Flight /createFromReadableStream等),代码块原样保留。
译者前言
这是 Tanner Linsley 在 2026-04-28 发的一篇短文,直接挑战 Next.js 主导的 RSC 心智模型——「server 拥有整棵树,client 只是这棵树上几个被 'use client' 标出来的洞」。
Tanner 的论点是:RSC 本质是一个协议(protocol),不是一种架构(architecture)。同一个协议既可以支撑「server-owned tree + client islands」,也可以支撑「client-owned tree + server-rendered fragments」(他们在 TanStack Start 里把后者命名为 Composite Components)。Next.js 只实现了前者,而 TanStack Start 两个都实现。
这篇文章的姊妹篇是同一团队 4 月初发的 React Server Components Your Way,本文是对那篇的 follow-up。结合 Code Mode 全文,你能看到 Tanner 在 2026 年的整体定位:TanStack 是 primitive provider,不和某一种架构(Next 的 server-first / Vercel 的部署平台)绑死。
读这篇的最佳姿势:你正在用 Next.js 做一个客户端交互很重(dashboard / SaaS console)的 app,被 RSC 的 server-first 默认逼得很难受——这篇文章给了你另一个出口。
Who Owns the Tree? RSC as a Protocol, Not an Architecture
谁拥有这棵树?RSC 是一种协议,而不是一种架构
原文: by Tanner Linsley on Apr 28, 2026.
原文:A few weeks ago we shipped React Server Components Your Way, and the most common follow-up was:
Why even bother with two RSC composition models? Pick one.
几周前我们发布了 React Server Components Your Way,最常见的追问是:
干嘛搞两套 RSC 组合模型?选一个就行了。
原文:When most people talk about RSCs, they’re usually referencing one specific architecture where the server owns the tree,
'use client'marks the holes, and the framework stitches everything together at hydration. That’s the model that people have in their heads when they say “RSC support”.
大多数人提到 RSC 时,脑子里其实是某一种特定架构:server 拥有整棵 tree,'use client' 标出洞,框架在 hydration 时把所有东西缝起来。当人们说「支持 RSC」时,心里想的就是这个模型。
原文:It’s an important model and even more important that people know and understand that TanStack Start supports it, but…
这是一个重要的模型,而且更重要的是:人们要知道并理解 TanStack Start 是支持它的——但是……
原文:RSC is also a protocol, a way to serialize rendered React output, client refs, and non-JSON stuff into a stream that can be streamed to the client and reconstructed. The “conventional” server-owned tree is just one way to use that protocol.
RSC 还是一个协议(protocol)——一种把已渲染的 React 输出、client refs、以及非 JSON 内容序列化到一个 stream 里、再 stream 给客户端去重建的方式。「常规」的 server-owned tree 只不过是使用这个协议的一种方式而已。
原文:So, who owns the tree?
所以,谁拥有这棵树?
原文:If and when the server owns it, you need a way to drop client interactivity into it, which is exactly what
'use client'is for. Hopefully this doesn’t come as a surprise. It’s in the react docs and how pretty much every RSC framework model works to this day, includin Start.
如果 server 拥有它,你就需要一种方式把 client 的交互塞进去——这正是 'use client' 做的事。这点应该不算意外:这写在 React 文档里,而且至今几乎所有 RSC 框架模型都是这么工作的,包括 Start。
原文:However, if the client owns it, you’ll need a way to drop server-rendered UI into it. That’s what Composite Components solve in TanStack Start.
但反过来,如果是 client 拥有这棵树,那你就需要一种方式把 server-rendered 的 UI 塞进去。这正是 TanStack Start 的 Composite Components 解决的问题。
原文:Both of these models are powered by the same protocol.
这两种模型背后,跑的是同一个协议。
🟢 译者注:这是整篇的核心论点——「server-owned tree」和「client-owned tree」是同一个 RSC 协议的两个方向。Tanner 想说的话其实是:Next.js 的同学,你们把协议和架构混为一谈了。
它的影响比你以为的大
原文:Alright, imagine you’re building a dashboard where the client owns almost everything: tabs, filters, drag layout, optimistic updates, the command palette, all of it. But one chart happens to pull from a crappy and slow analytics API, runs a bunch of server-only computation and happens to require hundreds of kilobytes of charting code just to produce the markup.
好,设想你在做一个 dashboard,client 几乎掌管一切:tabs、filters、拖拽布局、乐观更新、command palette,全在 client。但是其中一个图表碰巧要从一个又慢又烂的 analytics API 拉数据,跑一堆 server-only 计算,而且光生成它的 markup 就要几百 KB 的图表库代码。
原文:In the server-owned model approach, the obvious solution is to invert your whole route to the server, marking every interactive component along the way with
'use client'and hope that the boundaries land where you want them.
在 server-owned 模型里,显而易见的方案是:把整个 route 反转到 server 那一侧,顺路把每一个交互组件用 'use client' 标一遍,然后祈祷 boundary 最后落在你想要的位置。
原文:Don’t get me wrong, this totally works. People do this every day in Next.js. But what you really just did was adopted a server-first architecture for your whole app just to render one single server-shaped region into a mostly client-side controlled tree.
别误会,这完全可以工作。每天都有人在 Next.js 里这么干。但你实际上做的事是:为了把一块 server-shaped 的区域塞进一棵基本由 client 控制的 tree 里,你给整个 app 套了一层 server-first 架构。
原文:Now imagine the opposite being possible. Just keep the dashboard client-owned, ask the server through api/server function for the rendered chart markup, then drop it into the tree wherever you want, alongside whatever client state you already have.
现在设想反过来是可行的:dashboard 保持 client-owned,通过 API / server function 向 server 要那段已渲染的 chart markup,然后想塞哪儿就塞哪儿——和你已有的 client 状态并排放着。
import { createFromReadableStream } from '@tanstack/react-start/rsc'
function Dashboard() {
const { data: chart } = useSuspenseQuery({
queryKey: ['analytics-chart', range],
queryFn: async () =>
createFromReadableStream(await getAnalyticsChart({ data: { range } })),
})
return (
<DashboardShell>
<Filters />
<Tabs>
<Tab label="Overview">
{/* Server-rendered output, dropped into a client-owned tree */}
{chart}
</Tab>
<Tab label="Raw">
<ClientOnlyTable />
</Tab>
</Tabs>
</DashboardShell>
)
}
原文:The seam just moved a bit, and it’s more transparent than what you’re used to. And guess what, it’s a totally valid and awesome way to use the RSC protocol.
接缝(seam)只是稍微挪了一下,而且比你习惯的方式更透明。而且你猜怎么着——这是完全合法、而且非常好的一种使用 RSC 协议的方式。
🟢 译者注:这段代码是这篇文章的核心 demo——用 useSuspenseQuery(TanStack Query)去 fetch 一段 RSC 流,用 createFromReadableStream 还原成 React 元素,然后像普通 children 一样插进 client 的 JSX。本质是让 RSC 的 Flight 协议变成 TanStack Query 的一种 data type。
两种模型都能跑到极端
原文:There is a fun symmetry here where both models can reach the opposite extreme pretty easily.
有意思的对称性是:两种模型都能很轻松地跑到对方的极端。
原文:If you use a Server-owned model, just push
'use client'high enough in the tree and the route effectively becomes an SPA. The server still owns the outer entry, but everything below the boundary is client-composed.
如果你用 server-owned 模型,把 'use client' 往 tree 上方推到足够高,这个 route 实际上就变成 SPA 了。Server 还是拥有外层入口,但 boundary 以下的一切都是 client 组合的。
原文:If it’s client-owned, render a server component as high as you can in the tree and the route suddenly behaves like a server-rendered page.
如果是 client-owned,把一个 server component 渲染在 tree 上尽可能高的位置,这个 route 突然就像一个 server-rendered 页面。
原文:Something interesting to point out though is that in both models, the client still receives and composes the result, even if that result is fully server-rendered*.
值得指出的是:两种模型里,最终都是 client 在接收并组合结果——哪怕结果是完全 server-rendered 的。
原文:So the real distinction isn’t which model can technically reach which outcome. Both can do it. It’s more about which side the framework naturally pulls you toward, and how much friction you hit when you move the other direction.
所以真正的区别不是哪个模型在技术上能到哪个结果,两个都能。而是:框架天然把你往哪一侧拉、你往反方向走时摩擦力有多大。
原文:If you’re shipping an eventual-SPA, the client is the final destination of the UI whether you like it or not, which is why I frequently make the case that the “client-owned” composition model with strong server composition primitives covers more ground and capability than the other. It keeps the control layer rooted where the user experience actually runs while still letting you do server-rendered regions, server-only computation, streaming, caching, and progressive enhancement wherever they make sense.
如果你最终是在交付一个 eventual-SPA(最终 SPA 化的应用),不管你乐不乐意,client 都是 UI 的最终归宿——这也是为什么我经常主张:「client-owned 组合模型 + 强 server 组合原语」覆盖的场景和能力比另一种更多。它把控制层钉在用户体验真正运行的那一侧,同时仍然允许你在合适的地方用 server-rendered 区域、server-only 计算、streaming、caching 和 progressive enhancement。
🟢 译者注:这段是 Tanner 的论证核心——「eventual-SPA」这个术语很关键。Tanner 的判断是:大多数现代 web 应用最后都是 SPA-shaped(交互重),所以 client-owned 是更现实的默认。这个判断和 Next.js 的 server-first 默认是直接对立的。
为什么大多数框架只发布了一半
原文: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.
标准 RSC 模型默认 tree 是 server-owned 的,所以 primitive 都围绕这个方向设计。'use client'、hydration boundary、streaming、Suspense 围栏、manifest-driven 的 reference 解析,都假设 server 在组合、client 在接收。
原文:That’s fine for what those frameworks were built for and **TanStack Start supports
'use client'exactly the same.
这对那些框架最初要解决的问题来说没问题,TanStack Start 对 'use client' 的支持完全一致。
原文:However, other frameworks don’t really have a great answer when you ask:
How do I render this server fragment inside a client tree I’m already composing?
or
How can I fetch and cache a server component from a useQuery/useEffect?
但其他框架在面对下面这两个问题时,真的拿不出什么好答案:
我怎么把这段 server fragment 渲染到一棵已经在我手里组合的 client tree 里?
或者:
我怎么从
useQuery/useEffect里 fetch 并缓存一个 server component?
原文:The closest answer is usually “make the client thing a
'use client'boundary inside a server tree” which works when the route is server-shaped, but when the route is client-shaped, you’re inverting the entire architecture for little ROI.
最接近的答案通常是「把 client 那一坨东西作为 'use client' boundary 塞进一棵 server tree 里」——当 route 本身是 server-shaped 的时候这能工作,但当 route 本身是 client-shaped 时,你是为了一点点 ROI 而反转整个架构。
原文:Composite Components fill that gap. A server function returns a rendered React fragment as Flight data. The client passes it to
<CompositeComponent>and provides slots throughchildrenor render props. The server-rendered fragment can position those slots, but the client still owns the surrounding tree.
Composite Components 补上的就是这个缺口。一个 server function 返回一段已渲染的 React fragment,作为 Flight data。Client 把它传给 <CompositeComponent>,并通过 children 或 render props 提供 slot。Server-rendered 的 fragment 可以摆放这些 slot,但周围的 tree 仍然由 client 拥有。
// server
const getPost = createServerFn().handler(async ({ data }) => {
const post = await db.posts.get(data.postId)
return {
src: await createCompositeComponent(
({ children }: { children?: React.ReactNode }) => (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<footer>{children}</footer>
</article>
),
),
}
})
// client
function PostPage({ postId }: { postId: string }) {
const { data } = useSuspenseQuery({
queryKey: ['post', postId],
queryFn: () => getPost({ data: { postId } }),
})
return (
<CompositeComponent src={data.src}>
<Comments postId={postId} />
</CompositeComponent>
)
}
原文:This is additive and textbook inversion of control. Yes, start still supports
'use client'when you want server-owned composition, but Composite Components enable the same idea from the opposite control plane.
这是附加式的,教科书级别的 inversion of control(控制反转)。是的,当你想要 server-owned 组合时,Start 仍然支持 'use client';但 Composite Components 让同样的思路从反向的控制平面生效。
原文:This isn’t even foreign to React’s original framing. The first Server Components RFC describes Client Components as the regular components you already know, and notes that Server Components can pass other Server Components as children to Client Components. From the Client Component’s perspective, that child is already rendered output. The same RFC also describes granular refetching from multiple entry points as part of the design direction even though the initial demo refetched the whole app.
这甚至不算偏离 React 原本的框架。第一份 Server Components RFC 把 Client Components 描述成「就是你已经熟悉的那些常规组件」,并指出 Server Components 可以把其他 Server Components 作为 children 传给 Client Components。从 Client Component 的视角看,那个 child 就是一段已渲染的输出。同一份 RFC 也把「从多个 entry point 做细粒度 refetch」列为设计方向之一——尽管最初的 demo 是整个 app 一起 refetch 的。
原文:So ironically, this inverse model is even closer to the original concepts by taking the protocol seriously and exposing a composition direction the protocol already makes possible.
讽刺的是:这种反向模型反而更贴近原始概念——它认真对待协议本身,暴露出协议本来就允许的一个组合方向。
🟢 译者注:这段是 Tanner 在打回旋镖——他在说:Composite Components 不是 TanStack 自创的怪招,React 团队最早的 RFC 里就写了。问题只是 Next.js 这条路线的实现没有把这个方向暴露出来。
一个强大的 primitive,而不是整条 pipeline
原文:“RSC support” is a phrase that feels overused and overstated to mean something like “correct” or “blessed”, which is a weird thing to say about a primitive. I think more accurately, frameworks are using the RSC primitives in the way that has been revealed and marketed to them thus far, which is fine if that model covers your use cases. Ours needed more.
「RSC support」这个说法被过度使用、过度抬举,几乎成了「正确」或「被认证」的代名词——把这种话用在一个 primitive 上是很奇怪的。我觉得更准确的说法是:框架们是在按目前被揭示和被营销出来的那种方式使用 RSC primitive——如果那种模型覆盖了你的用例,完全没问题。但我们的需求更多。
原文:RSC is more than serialization. It’s React’s attempt to bring data fetching, streaming, code splitting, server access, and client interactivity into one coherent model. Worthy goal, but it’s still one way to organize those concerns, and most of those concerns already have great answers elsewhere in the ecosystem.
RSC 不止是序列化。它是 React 想把 data fetching、streaming、code splitting、server access 和 client interactivity 统一成一个一致模型的一次尝试。目标值得追求,但它仍然只是组织这些关切的一种方式——而且这些关切里大多数,生态里其他地方已经有很好的答案了。
原文:Routing solves waterfalls. Loaders kick off data work before render. Query libraries dedupe, cache, prefetch, stream, and invalidate. HTTP and CDNs cache responses. Server functions expose backend work without dragging the whole tree onto the server.
Routing 解决 waterfall。Loader 在 render 之前发起数据工作。Query 库做去重、缓存、prefetch、streaming 和 invalidation。HTTP 和 CDN 缓存响应。Server function 把后端工作暴露出来,而不必把整棵 tree 拖到 server 上。
原文:So the question isn’t whether RSC can solve these problems. It can. The question is whether routing every concern through a rigid RSC architecture should be the default answer for every app.
所以问题不是 RSC 能不能解决这些问题——它能。问题是:把每一个关切都从一套刚性的 RSC 架构里走一遍,该不该是每个 app 的默认答案。
原文:Start’s answer is no. RSC is a powerful primitive in the pipeline, not the pipeline itself. Use it where rendered server UI is the right abstraction, and use the rest of the toolkit where those fit better. That’s not rejecting RSC, it’s rejecting RSC-as-silver-bullet for problems smaller, more composable tools already solve.
Start 的回答是「不」。RSC 是 pipeline 里一个强大的 primitive,而不是 pipeline 本身。在「rendered server UI 是正确抽象」的地方用它,在其他更合适的地方用别的工具。这不是在拒绝 RSC,这是在拒绝**「把 RSC 当银弹」**——更小、更可组合的工具已经解决了那些问题。
原文:That same instinct is why Start also doesn’t ship a caching directive.
同样的直觉也解释了为什么 Start 不发 caching directive。
🟢 译者注:这一节是 Tanner 的方法论自白——「primitive over pipeline」。你能在 TanStack 一系列产品(Query / Router / Form / DB)看到同一姿态:每个都只解决一件事,但是 first-class 地解决,然后让你自由组合。
为什么 Start 不发 caching directive
原文:People keep asking why we don’t ship something like Next’s
"use cache". Short answer: that directive assumes a framework- or platform-owned persistence layer, and Start can’t honestly make that assumption.
总有人问我们为什么不发布一个类似 Next 的 "use cache" 的东西。短答:那个 directive 假设了一个由 framework 或 platform 拥有的持久化层,而 Start 没法诚实地做这个假设。
原文:The directive marks something cacheable and the runtime handles the rest; keys, storage, invalidation, durability, cross-instance sharing, all the deployment-specific stuff. Which means the framework (or the platform under it) has to own the persistence contract. The directive doesn’t eliminate that question, it just hides it behind a one-liner at the call site.
这种 directive 的工作方式是:你标记某个东西「可缓存」,然后 runtime 处理其余一切——key、存储、invalidation、durability、跨 instance 共享,所有这些 deployment-specific 的东西。这就意味着 framework(或者它下面的 platform)必须拥有这份持久化契约。这个 directive 并没有消除那个问题,只是把它藏到了调用处的一行代码后面。
原文:That works great when your framework is married to a specific platform. Start isn’t. Cloudflare Workers, Netlify, Vercel, Node, Bun, Railway, any Nitro target; there’s no single portable persistence layer across all of that, so there’s no honest directive shape that means the same thing everywhere.
当你的 framework 跟某个特定 platform 绑死时,这套 directive 工作得很好。Start 没有绑死。Cloudflare Workers、Netlify、Vercel、Node、Bun、Railway、任何 Nitro target——这些之间根本不存在一个统一的可移植持久化层,所以也就不存在一个能在每处都意味着同一件事的、诚实的 directive 形态。
原文:Start takes the transparent route instead. A server function returns a Flight stream as bytes, and those bytes can be cached at any layer you already control.
Start 走的是透明路线。一个 server function 把 Flight stream 作为字节返回,这些字节可以在你已经掌控的任何一层做缓存。
| Layer | Cache option |
|---|---|
| Render pass | React.cache 在一次 render 内做去重 |
| Server | Redis、KV、Postgres、内存 LRU,或者你正在用的任何东西 |
| Network | HTTP caching 和 Cache-Control header |
| Client | Router cache、TanStack Query,或任何客户端 store |
原文:
createFromReadableStreamdecodes the bytes at render time, after the cache boundary. The cacheable primitive isn’t a directive that hides persistence; it’s transparent RSC output flowing through cache layers your app already understands.
createFromReadableStream 在 render 时把字节解码——也就是在 cache boundary 之后。这个可缓存原语不是一个把持久化藏起来的 directive,而是透明的 RSC 输出流过你应用已经理解的那些 cache 层。
原文:A directive is the right shape when the framework and platform can own the cache contract. Transparent bytes is the right shape when the framework needs to stay portable. We chose portable.
当 framework 和 platform 能拥有 cache 契约时,directive 是对的形态。当 framework 需要保持 portable 时,透明字节才是对的形态。我们选了 portable。
🟢 译者注:这是整篇里最有价值的一段政治表态——直接对应 Vercel "use cache" 路线。Tanner 的潜台词是:Vercel 之所以能发 "use cache",是因为它们假设你部署在 Vercel(或同等假设的平台),而 TanStack 不愿意做这个假设。这是 portable framework vs. platform framework 的根本分歧。
Closing(结语)
原文:The better question isn’t “does this framework support RSC?” It’s:
Which RSC composition models does it expose?
更好的问题不是「这个框架支不支持 RSC?」,而是:
它暴露的是哪些 RSC composition model?
原文:TanStack Start exposes both, and you can mix them per route, per component, per use case. Same TanStack philosophy as always: you know what’s best for your application, and the framework should get out of the way.
TanStack Start 两个都暴露——而且你可以按 route、按 component、按 use case 混用。还是那套 TanStack 哲学:你最清楚什么对你的应用最好,框架应该让开。
原文:If you’ve been waiting for an RSC story that doesn’t ask you to invert your whole architecture, this is it. RSC support in TanStack Start is experimental and ready to play with.
如果你一直在等一种不要求你反转整个架构的 RSC 故事,就是它了。TanStack Start 的 RSC 支持目前还是 experimental,可以试玩。
译者总评
1. 这篇文章为什么是 2026 前端的关键拼图。 2026 年关于 RSC 的讨论已经从「RSC 怎么用」走到了「RSC 该不该当默认架构」。Tanner 这篇是 client-owned 阵营的一份檄文。它没有否定 RSC,而是把 RSC 还原成它真正的位置:一个协议、一个 primitive,不是 pipeline 本身。如果你觉得 Next.js 的 server-first 默认让你很难写客户端重的应用,这篇文章就是你需要的语言工具。
2. Composite Components 的核心创新点。
- 方向反转:不是 server 拥有 tree、client 是 holes;而是 client 拥有 tree、server 渲染的 fragment 通过 Flight 协议作为 first-class 数据流入。
- 配合 TanStack Query:
useSuspenseQuery({ queryFn: () => createFromReadableStream(...) })这个 pattern 把 RSC 流变成 TanStack Query 的一种 data type——这是 TanStack 生态独有的优势。 - Slot 通过 children:server-rendered fragment 可以摆放 client-provided 的 slot,做到 server 决定布局、client 决定具体内容。
3. 不发 "use cache" 这件事的产品哲学。
这是整篇里最值得长期记住的一段。"use cache" 是 platform-married framework 的形态;transparent bytes 是 portable framework 的形态。这个对立未来几年会反复出现:Vercel(以及 Cloudflare Workers 平台向)走 directive 路线,TanStack / 其他 portable framework 走透明字节路线。你怎么选,主要取决于你愿不愿意把部署平台锁定。
4. 局限与风险。
- TanStack Start 的 RSC 支持当前还是 experimental,这意味着 API 可能动。
- Composite Components 是新概念,生态(类型工具、ESLint 规则、IDE 提示)还没跟上 Next.js 的成熟度。
- 「client-owned」的代价是:你失去了 Next.js 那种「整个应用一份 manifest、一份 build pipeline、一份 cache 策略」的统一性。要换更多 primitive 自己拼。
- 这篇文章本身论战意味很重,落到工程实践时,server-first 在 SEO、TTFB、初始流式的表现上仍然有不可替代的优势——别因为读了这篇就一刀切。
5. 怎么给团队推荐这篇。 如果你的团队正在选 framework,或者已经在 Next.js 上挣扎,把这篇文章和 React Server Components Your Way 一起读。读完后回答一个问题:你的应用在「server-shaped vs. client-shaped」光谱上落在哪里? 这个问题决定了你应该选 server-owned 默认还是 client-owned + 强 server primitive。
🔗 调研来源
- 原文 Who Owns the Tree: https://tanstack.com/blog/who-owns-the-tree
- 姊妹篇 RSC Your Way: https://tanstack.com/blog/react-server-components
- 配套精读: Cursor SDK + Zed 1.0:编辑器赛道转向
- 兄弟全文: Tanner Linsley — Code Mode(全文)
📝 配套精读 + 译者点评:Cursor SDK + Zed 1.0:编辑器赛道转向