Supabase 仓库实践指南:React 组合模式——用复合组件与状态依赖注入摆脱布尔 Prop 蔓延
本篇技术文章基于 Supabase 仓库内置的 .claude/skills/vercel-composition-patterns/ 技能文档(由 Vercel 编写的可复用 AI 技能),系统讲解一整套可规模化扩展的 React 组合模式:避免布尔 prop 蔓延、复合组件(Compound Components)、状态提升与 state/actions/meta 三段式 Context 接口、显式变体、children 优于 render props,以及 React 19 的 use() 与 ref-as-prop 变更。读完本文,你将掌握一套完整的组件架构方法,并能在当前仓库的 React 19 代码库中识别、套用这些模式。
一、这套模式的定位与适用场景
该技能文档(SKILL.md)开篇即点明目标:构建灵活、可维护的 React 组件,通过复合组件、状态提升与内部组件组合来避免布尔 prop 蔓延。文档同时强调这些模式“让代码库对人类和 AI Agent 都更友好”——这与 Supabase 仓库将文档组织为 Agent 技能(skill)的初衷一致。
文档明确给出了五种应参考这些准则的场景:
- 重构带有很多布尔 prop 的组件;
- 构建可复用的组件库;
- 设计灵活的组件 API;
- 评审组件架构;
- 处理复合组件或 Context Provider。
规则优先级分类
SKILL.md 将全部 8 条规则按优先级分为四类,这个优先级表本身就是文章的核心骨架:
| 优先级 | 类别 | 影响 | 规则前缀 |
|---|---|---|---|
| 1 | 组件架构(Component Architecture) | HIGH | architecture- |
| 2 | 状态管理(State Management) | MEDIUM | state- |
| 3 | 实现模式(Implementation Patterns) | MEDIUM | patterns- |
| 4 | React 19 APIs | MEDIUM | react19- |
每条规则对应 rules/ 目录下的一个独立文件,文件结构统一为:简述为何重要、错误示例及解释、正确示例及解释、附加上下文与参考。8 个规则文件分别位于 rules/architecture-avoid-boolean-props.md、rules/architecture-compound-components.md、rules/state-decouple-implementation.md、rules/state-context-interface.md、rules/state-lift-state.md、rules/patterns-explicit-variants.md、rules/patterns-children-over-render-props.md、rules/react19-no-forwardref.md。
二、组件架构(HIGH):从布尔 Prop 到复合组件
这是优先级最高的一类规则,包含两条:architecture-avoid-boolean-props(影响等级 CRITICAL)与 architecture-compound-components(影响等级 HIGH)。
2.1 避免布尔 Prop 蔓延
核心论断是:不要为定制组件行为而添加 isThread、isEditing、isDMThread 这类布尔 prop。每一个布尔 prop 都会使可能状态数翻倍,制造不可维护的条件分支。
文档给出的反例是一个典型的"巨型 Composer"——用 4 个布尔 prop 控制 2 组条件渲染,状态组合已经指数化:
function Composer({
onSubmit,
isThread,
channelId,
isDMThread,
dmId,
isEditing,
isForwarding,
}: Props) {
return (
<form>
<Header />
<Input />
{isDMThread ? (
<AlsoSendToDMField id={dmId} />
) : isThread ? (
<AlsoSendToChannelField id={channelId} />
) : null}
{isEditing ? (
<EditActions />
) : isForwarding ? (
<ForwardActions />
) : (
<DefaultActions />
)}
<Footer onSubmit={onSubmit} />
</form>
)
}
正确做法是:每个变体显式声明自己渲染什么,共享内部件(Composer.Input、Composer.Footer 等)而不共享单一庞大父组件:
// Channel composer
function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Attachments />
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
// Thread composer - adds "also send to channel" field
function ThreadComposer({ channelId }: { channelId: string }) {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<AlsoSendToChannelField id={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
// Edit composer - different footer actions
function EditComposer() {
return (
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.CancelEdit />
<Composer.SaveEdit />
</Composer.Footer>
</Composer.Frame>
)
}
文档对此的总结值得直接引用:"每个变体都明确知道自己渲染什么。我们可以共享内部件,而无需共享单一的庞大父组件。"
2.2 使用复合组件(Compound Components)
规则 architecture-compound-components 给出结构性方案:将复杂组件结构化为共享 Context 的复合组件,每个子组件通过 Context 而非 props 访问共享状态,消费者只组合自己需要的部分。
先看反例——一个混合了 render props 和布尔开关的单体组件:
function Composer({
renderHeader,
renderFooter,
renderActions,
showAttachments,
showFormatting,
showEmojis,
}: Props) {
return (
<form>
{renderHeader?.()}
<Input />
{showAttachments && <Attachments />}
{renderFooter ? (
renderFooter()
) : (
<Footer>
{showFormatting && <Formatting />}
{showEmojis && <Emojis />}
{renderActions?.()}
</Footer>
)}
</form>
)
}
正确实现拆为"Provider + Frame + 各内部件",并以命名空间对象的形式对外导出:
const ComposerContext = createContext<ComposerContextValue | null>(null)
function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
return (
<ComposerContext value={{ state, actions, meta }}>
{children}
</ComposerContext>
)
}
function ComposerFrame({ children }: { children: React.ReactNode }) {
return <form>{children}</form>
}
function ComposerInput() {
const {
state,
actions: { update },
meta: { inputRef },
} = use(ComposerContext)
return (
<TextInput
ref={inputRef}
value={state.input}
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
/>
)
}
function ComposerSubmit() {
const {
actions: { submit },
} = use(ComposerContext)
return <Button onPress={submit}>Send</Button>
}
// Export as compound component
const Composer = {
Provider: ComposerProvider,
Frame: ComposerFrame,
Input: ComposerInput,
Submit: ComposerSubmit,
Header: ComposerHeader,
Footer: ComposerFooter,
Attachments: ComposerAttachments,
Formatting: ComposerFormatting,
Emojis: ComposerEmojis,
}
调用侧的组合方式:
<Composer.Provider state={state} actions={actions} meta={meta}>
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</Composer.Provider>
文档强调两点收益:消费者显式地只组合需要的部分、没有隐藏的条件分支;state/actions/meta 由父级 Provider 依赖注入,因此同一套组件结构可以被多处复用。
仓库源码印证:Supabase 自己的 UI 包中已经存在这种"复合组件 + 共享 Context"的真实实现。例如 MenuContext.tsx 中,Menu 组件用 createContext 建立 MenuContext(带 { type: 'text' } 默认值),MenuContextProvider 负责下发 value,并额外导出一个 useMenuContext 辅助 Hook——在消费者侧若脱离 Provider 使用会直接抛出 MenuContext must be used within a MenuContextProvider. 错误。从源码结构看,这就是文档所述"子组件通过 Context 而非 props 获取共享状态"模式的落地形态:Provider 是状态/配置的单一来源,内部件各自订阅所需切片。该仓库的 packages/ui 通过 pnpm-workspace.yaml 的 catalog 锁定 react: ^19.2.6,因此文档第 4 类 React 19 规则在本仓库是可直接套用的。
三、状态管理(MEDIUM):提升、解耦与泛型 Context 接口
第二类规则共三条:state-lift-state(HIGH,影响"让组件边界之外的状态可共享")、state-decouple-implementation(MEDIUM,"Provider 是唯一知道状态如何管理的地方")、state-context-interface(HIGH,"定义 state/actions/meta 三段式泛型接口实现依赖注入")。三者构成一条完整推导链:先把状态提升进 Provider,再定义泛型接口,最终让 UI 与状态实现彻底解耦。
3.1 把状态提升进 Provider(state-lift-state)
问题场景:ForwardMessageComposer 内部持有 useState,而对话框里的 MessagePreview 需要读输入内容、ForwardButton 需要调用提交——状态被"困"在组件内部。文档列举了三种常见而糟糕的绕过方式,值得逐一对照检查:
错误一:状态被困在组件内部
function ForwardMessageComposer() {
const [state, setState] = useState(initialState)
const forwardMessage = useForwardMessage()
return (
<Composer.Frame>
<Composer.Input />
<Composer.Footer />
</Composer.Frame>
)
}
// Problem: How does this button access composer state?
function ForwardMessageDialog() {
return (
<Dialog>
<ForwardMessageComposer />
<MessagePreview /> {/* Needs composer state */}
<DialogActions>
<CancelButton />
<ForwardButton /> {/* Needs to call submit */}
</DialogActions>
</Dialog>
)
}
错误二:用 useEffect 把状态"同步"给父级
function ForwardMessageDialog() {
const [input, setInput] = useState('')
return (
<Dialog>
<ForwardMessageComposer onInputChange={setInput} />
<MessagePreview input={input} />
</Dialog>
)
}
function ForwardMessageComposer({ onInputChange }) {
const [state, setState] = useState(initialState)
useEffect(() => {
onInputChange(state.input) // Sync on every change
}, [state.input])
}
错误三:提交时从 ref 读状态
function ForwardMessageDialog() {
const stateRef = useRef(null)
return (
<Dialog>
<ForwardMessageComposer stateRef={stateRef} />
<ForwardButton onPress={() => submit(stateRef.current)} />
</Dialog>
)
}
正确做法是把状态整体提升到专门的 Provider:
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState(initialState)
const forwardMessage = useForwardMessage()
const inputRef = useRef(null)
return (
<Composer.Provider
state={state}
actions={{ update: setState, submit: forwardMessage }}
meta={{ inputRef }}
>
{children}
</Composer.Provider>
)
}
function ForwardMessageDialog() {
return (
<ForwardMessageProvider>
<Dialog>
<ForwardMessageComposer />
<MessagePreview /> {/* Custom components can access state and actions */}
<DialogActions>
<CancelButton />
<ForwardButton /> {/* Custom components can access state and actions */}
</DialogActions>
</Dialog>
</ForwardMessageProvider>
)
}
function ForwardButton() {
const { actions } = use(Composer.Context)
return <Button onPress={actions.submit}>Forward</Button>
}
文档提炼出的关键洞见(Key insight)是:需要共享状态的组件不必在视觉上嵌套于彼此内部,只要位于同一个 Provider 之内即可。ForwardButton 位于 Composer.Frame 之外,仍能拿到 submit 动作。
3.2 定义 state / actions / meta 三段式泛型 Context 接口(state-context-interface)
这条规则把复合组件的模式抽象成一个类型契约:
// Define a GENERIC interface that any provider can implement
interface ComposerState {
input: string
attachments: Attachment[]
isSubmitting: boolean
}
interface ComposerActions {
update: (updater: (state: ComposerState) => ComposerState) => void
submit: () => void
}
interface ComposerMeta {
inputRef: React.RefObject<TextInput>
}
interface ComposerContextValue {
state: ComposerState
actions: ComposerActions
meta: ComposerMeta
}
const ComposerContext = createContext<ComposerContextValue | null>(null)
三个部分的分工很清晰:state 是只读数据快照,actions 是受控的变更入口(注意 update 采用函数式 updater 签名,等价于 setState 的语义),meta 承载 ref 等非状态性的元数据。UI 组件只消费这个接口:
function ComposerInput() {
const {
state,
actions: { update },
meta,
} = use(ComposerContext)
// This component works with ANY provider that implements the interface
return (
<TextInput
ref={meta.inputRef}
value={state.input}
onChangeText={(text) => update((s) => ({ ...s, input: text }))}
/>
)
}
该规则进一步展示了两个 Provider 实现同一接口的能力——ForwardMessageProvider 用 useState(临时表单的本地状态),ChannelProvider 用 useGlobalChannel(channelId)(全局同步状态)——而同一段组合式 UI 对两者通吃:
// Works with ForwardMessageProvider (local state)
<ForwardMessageProvider>
<Composer.Frame>
<Composer.Input />
<Composer.Submit />
</Composer.Frame>
</ForwardMessageProvider>
// Works with ChannelProvider (global synced state)
<ChannelProvider channelId="abc">
<Composer.Frame>
<Composer.Input />
<Composer.Submit />
</Composer.Frame>
</ChannelProvider>
文档还专门讨论了Provider 边界而非视觉嵌套这一点:ForwardMessageDialog 里,MessagePreview 与 ForwardButton 都位于 Composer.Frame 之外、ForwardMessageProvider 之内,却能分别读取 state.input / state.attachments 并调用 submit。原文的总结一针见血:"UI 是你组合起来的可复用积木,状态由 Provider 依赖注入。换掉 Provider,UI 保持不变(Swap the provider, keep the UI)。"
3.3 将状态管理与 UI 解耦(state-decouple-implementation)
这条规则是前述两点的收束:Provider 组件应当是唯一知道状态如何管理的地方;UI 组件只消费 Context 接口——它们不知道状态来自 useState、Zustand 还是服务端同步。
反例展示了 UI 与全局状态实现直接耦合的样子:
function ChannelComposer({ channelId }: { channelId: string }) {
// UI component knows about global state implementation
const state = useGlobalChannelState(channelId)
const { submit, updateInput } = useChannelSync(channelId)
return (
<Composer.Frame>
<Composer.Input
value={state.input}
onChange={(text) => sync.updateInput(text)}
/>
<Composer.Submit onPress={() => sync.submit()} />
</Composer.Frame>
)
}
正例则把 useGlobalChannel 的调用完全收进 ChannelProvider,UI 侧的 ChannelComposer 只剩纯结构声明:
// Provider handles all state management details
function ChannelProvider({
channelId,
children,
}: {
channelId: string
children: React.ReactNode
}) {
const { state, update, submit } = useGlobalChannel(channelId)
const inputRef = useRef(null)
return (
<Composer.Provider
state={state}
actions={{ update, submit }}
meta={{ inputRef }}
>
{children}
</Composer.Provider>
)
}
// UI component only knows about the context interface
function ChannelComposer() {
return (
<Composer.Frame>
<Composer.Header />
<Composer.Input />
<Composer.Footer>
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
)
}
// Usage
function Channel({ channelId }: { channelId: string }) {
return (
<ChannelProvider channelId={channelId}>
<ChannelComposer />
</ChannelProvider>
)
}
这条规则的工程价值在于替换成本被限制在 Provider 一层:从 useState 迁到外部状态库时,Composer.Input、Composer.Submit 等内部件零改动。
四、实现模式(MEDIUM):显式变体与 children 优先
4.1 创建显式变体组件(patterns-explicit-variants)
与 2.1 节的布尔 prop 问题互为表里:与其维护"一个组件 + N 个布尔模式",不如为每种场景建立显式变体组件。对比一下调用侧的可读性差异:
// What does this component actually render?
<Composer
isThread
isEditing={false}
channelId='abc'
showAttachments
showFormatting={false}
/>
// Immediately clear what this renders
<ThreadComposer channelId="abc" />
// Or
<EditMessageComposer messageId="xyz" />
// Or
<ForwardMessageComposer messageId="123" />
每个变体的实现同时自带对应的 Provider,一次性显式声明三件事:使用哪个 Provider/状态、包含哪些 UI 元素、提供哪些动作。以三个完整变体为例:
function ThreadComposer({ channelId }: { channelId: string }) {
return (
<ThreadProvider channelId={channelId}>
<Composer.Frame>
<Composer.Input />
<AlsoSendToChannelField channelId={channelId} />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Submit />
</Composer.Footer>
</Composer.Frame>
</ThreadProvider>
)
}
function EditMessageComposer({ messageId }: { messageId: string }) {
return (
<EditMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.CancelEdit />
<Composer.SaveEdit />
</Composer.Footer>
</Composer.Frame>
</EditMessageProvider>
)
}
function ForwardMessageComposer({ messageId }: { messageId: string }) {
return (
<ForwardMessageProvider messageId={messageId}>
<Composer.Frame>
<Composer.Input placeholder="Add a message, if you'd like." />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<Composer.Mentions />
</Composer.Footer>
</Composer.Frame>
</ForwardMessageProvider>
)
}
文档的结论:没有需要推理的布尔组合,也就不存在"不可能的状态"。
4.2 children 优于 render props(patterns-children-over-render-props)
组合静态结构时,优先用 children 而不是 renderX prop。反例中 renderHeader/renderFooter/renderActions 三个回调 prop 使调用侧冗长且必须理解每个回调签名:
// Usage is awkward and inflexible
return (
<Composer
renderHeader={() => <CustomHeader />}
renderFooter={() => (
<>
<Formatting />
<Emojis />
</>
)}
renderActions={() => <SubmitButton />}
/>
)
正例改为让 ComposerFrame 与 ComposerFooter 都接收 children,调用侧回归直观的声明式嵌套:
function ComposerFrame({ children }: { children: React.ReactNode }) {
return <form>{children}</form>
}
function ComposerFooter({ children }: { children: React.ReactNode }) {
return <footer className='flex'>{children}</footer>
}
// Usage is flexible
return (
<Composer.Frame>
<CustomHeader />
<Composer.Input />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
<SubmitButton />
</Composer.Footer>
</Composer.Frame>
)
规则同时给出了 render props 的适用边界——当父组件需要向子项回传数据时,render props 反而更合适:
// Render props work well when you need to pass data back
<List
data={items}
renderItem={({ item, index }) => <Item item={item} index={index} />}
/>
判定标准可以概括为:父组件要向子组件提供数据或状态 → render props;组合静态结构 → children。
五、React 19 API 变更(react19-no-forwardref)
文档以醒目提示声明此条规则仅适用于 React 19+,React 18 及更早版本应跳过。Supabase 仓库的前端 catalog 在 pnpm-workspace.yaml 中统一锁定 react: ^19.2.6(各包如 packages/ui/package.json 通过 catalog: 引用该版本),因此本仓库的 React 代码可以直接按此条规则编写。两条变更:
1. ref 成为普通 prop,不再需要 forwardRef 包裹:
// Incorrect (forwardRef in React 19)
const ComposerInput = forwardRef<TextInput, Props>((props, ref) => {
return <TextInput ref={ref} {...props} />
})
// Correct (ref as a regular prop)
function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
return <TextInput ref={ref} {...props} />
}
2. 用 use() 替代 useContext():
// Incorrect (useContext in React 19)
const value = useContext(MyContext)
// Correct (use instead of useContext)
const value = use(MyContext)
文档补充了一个关键差异:use() 可以条件调用,而 useContext() 不行——这在"按需订阅 Context 切片"的场景中是实际能力差异。
从源码结构看,仓库现有 UI 组件(如上文 MenuContext.tsx 的 useMenuContext 辅助 Hook)目前仍采用 createContext + useContext 的传统写法,并且通过"脱离 Provider 即抛错"的辅助函数保证了 Context 契约的严格性——这套结构本身与文档推荐的复合组件模式完全兼容;在将这类组件逐步迁移到 React 19 新 API 时,只需把消费侧的 useContext(X) 替换为 use(X)、并在函数组件中直接以 ref prop 接收引用,即可对齐文档给出的目标形态。
六、模式选型速查与落地建议
将 8 条规则压缩为一份可操作的决策清单:
| 你遇到的情况 | 应套用的规则 |
|---|---|
组件 prop 中出现第 3 个以上 isXxx/showXxx 布尔 |
停止加布尔,拆分显式变体(architecture-avoid-boolean-props + patterns-explicit-variants) |
| 需要向组件注入多个可定制区域 | 复合组件 + children,弃用 renderX prop(architecture-compound-components + patterns-children-over-render-props) |
| 组件内部状态需要被外部兄弟组件读写 | 状态提升到 Provider(state-lift-state) |
| 同一 UI 要适配多种状态来源(本地 / 全局 / 服务端) | 定义 state/actions/meta 泛型接口,UI 只消费接口(state-context-interface + state-decouple-implementation) |
| 项目使用 React 19 | 移除 forwardRef,useContext 换 use()(react19-no-forwardref) |
落地时的三个判断点值得强调:其一,Provider 边界是逻辑边界而非视觉边界——只要位于 Provider 子树内,组件无论渲染在 DOM 的哪个位置都能访问状态与动作;其二,meta 通道(如 inputRef)让 Provider 可以持有并分发 ref 这类元数据,避免为"焦点管理"再开一条 prop 通道;其三,render props 与 children 并非对立,而是按"是否需要父级回传数据"分工。完整规则文本见各 rules/*.md 文件,每个文件都包含错误示例、正确示例与上下文说明,可单独引用为团队评审清单。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00