Supabase 仓库实践:用 state/actions/meta 三段式通用 Context 接口实现 UI 状态依赖注入
本文基于 supabase 仓库内 .claude/skills/vercel-composition-patterns/rules/state-context-interface.md 这条 Vercel 组合模式规则展开,讲解如何为 React 组件的 Context 定义“state + actions + meta”三段式通用接口。读完本篇,你将掌握让同一套组合式 UI 组件在“本地 useState”和“全局同步状态”等不同状态实现之间无缝切换的依赖注入技巧,并理解 provider 边界与视觉嵌套为何是两回事。
规则定位:这条规则在整个组合模式技能中的位置
该文档位于仓库的 Claude 技能目录 .claude/skills/vercel-composition-patterns/ 下。根据 SKILL.md 的说明,这个技能收录了一组“能随规模增长的 React 组合模式”,目标是避免布尔 props 泛滥、构建灵活的组件库,其规则按优先级分为四类:
| 优先级 | 类别 | 影响 | 前缀 |
|---|---|---|---|
| 1 | 组件架构 | HIGH | architecture- |
| 2 | 状态管理 | MEDIUM | state- |
| 3 | 实现模式 | MEDIUM | patterns- |
| 4 | React 19 API | MEDIUM | react19- |
本文的主角 state-context-interface 属于状态管理(state-)类别,核心收益(impact: HIGH)被描述为“让状态可以在不同使用场景之间依赖注入(enables dependency-injectable state across use-cases)”。它与同目录下的两条姊妹规则构成一条完整链路:
- state-lift-state.md:先把状态提升到 provider,解决“兄弟组件访问状态”的问题;
- state-context-interface.md:再为 provider 暴露的值定义通用接口,让 UI 与状态实现解耦;
- state-decouple-implementation.md:约束 provider 是“唯一知道状态如何被管理”的地方。
文档给出的核心原则一句话概括:Lift state, compose internals, make state dependency-injectable(提升状态、组合内部实现、让状态可依赖注入)。
三段式通用接口:state、actions、meta
规则要求:为组件的 Context 定义一个泛化接口(generic interface),它由三部分构成:
state:当前状态数据(只读消费);actions:状态变更方法(写入的入口);meta:非状态的引用型元数据,典型如 DOM ref。
以“消息输入框 Composer”为例,文档给出的正确写法如下:
// 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)
三个设计点值得注意:
- 接口本身不含任何实现细节。
ComposerState只描述“有哪些数据字段”,不关心它来自useState、外部 store 还是服务端同步; actions.update采用函数式更新签名(updater: (state) => state) => void,这与useState的setState语义天然兼容,使得useState可以直接作为update传入(见下文 Provider A);同时也不排斥换成 Redux/Zustand 风格的更新函数;meta专门承载 ref 这类“既非状态也非动作”的东西。文档用React.RefObject<TextInput>声明输入框 ref。这个模式在 supabase 仓库的 Studio 应用中也能找到佐证:例如 useFloatingToolbarDrag.ts 中的钩子签名useFloatingToolbarDrag(navRef: React.RefObject<HTMLElement | null>),同样把 ref 作为独立的元数据引用在逻辑之间传递,而不是混进状态对象里。
另外注意 createContext<ComposerContextValue | null>(null) 的初始值写法:Context 类型显式包含 null,消费方在 use(ComposerContext) 之后可以安全地做空值断言——“必须在 provider 内使用”由类型系统强制,而不是运行时猜测。
反模式:UI 直接耦合具体状态 Hook
文档首先展示了一个典型错误:UI 组件内部直接调用某个特定的状态 Hook。
function ComposerInput() {
// Tightly coupled to a specific hook
const { input, setInput } = useChannelComposerState()
return <TextInput value={input} onChangeText={setInput} />
}
问题在于:ComposerInput 从此只能配合 useChannelComposerState 这一个实现工作。一旦想在“转发消息”这种临时表单场景复用这个输入框,就必须再写一个平行组件,或者给原组件加布尔 props 切换行为——这正是该技能要反对的布尔 props 膨胀(参见 architecture-avoid-boolean-props.md)。
正确做法是: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 }))}
/>
)
}
这里 use(ComposerContext) 是 React 19 的新 API,替代了 useContext()(对应技能中的 react19-no-forwardref.md 规则:React 19 起不再需要 forwardRef,ref 可作为普通 prop 传递)。注意更新状态时用的是 update((s) => ({ ...s, input: text })) 这种函数式写法——它只依赖 ComposerActions.update 的签名,对底层是 setState 还是其他 store 完全无感知。
多个 Provider 实现同一接口:换 Provider,UI 不动
接口定义好之后,任何组件只要产出一个合法的 ComposerContextValue 就能成为 provider。文档给出了两个截然不同的实现:
Provider A:本地状态(临时表单场景)
function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState(initialState)
const inputRef = useRef(null)
const submit = useForwardMessage()
return (
<ComposerContext
value={{
state,
actions: { update: setState, submit },
meta: { inputRef },
}}
>
{children}
</ComposerContext>
)
}
Provider B:全局同步状态(频道消息场景)
function ChannelProvider({ channelId, children }: Props) {
const { state, update, submit } = useGlobalChannel(channelId)
const inputRef = useRef(null)
return (
<ComposerContext
value={{
state,
actions: { update, submit },
meta: { inputRef },
}}
>
{children}
</ComposerContext>
)
}
两个 provider 的内部机制完全不同——一个是 useState 本地状态,一个是 useGlobalChannel(channelId) 全局同步状态——但对 UI 来说暴露的是同一个 { state, actions, meta } 契约。于是同一套组合式 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>
这正是文档结尾那句话的完整含义:“The UI is reusable bits you compose together. The state is dependency-injected by the provider. Swap the provider, keep the UI.”(UI 是你自由组合的可复用积木;状态由 provider 依赖注入;换掉 provider,保留 UI。)
Provider 边界 ≠ 视觉嵌套:Frame 之外的组件也能取状态
这是该文档区别于普通 Context 教程的关键洞察:决定组件能否访问共享状态的是 provider 边界,而不是视觉嵌套。需要共享状态的组件不必位于 Composer.Frame 内部,只要在 provider 内部即可。
文档用一个“转发消息对话框”完整演示了这一点:
function ForwardMessageDialog() {
return (
<ForwardMessageProvider>
<Dialog>
{/* The composer UI */}
<Composer.Frame>
<Composer.Input placeholder="Add a message, if you'd like." />
<Composer.Footer>
<Composer.Formatting />
<Composer.Emojis />
</Composer.Footer>
</Composer.Frame>
{/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
<MessagePreview />
{/* Actions at the bottom of the dialog */}
<DialogActions>
<CancelButton />
<ForwardButton />
</DialogActions>
</Dialog>
</ForwardMessageProvider>
)
}
// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
function ForwardButton() {
const {
actions: { submit },
} = use(ComposerContext)
return <Button onPress={submit}>Forward</Button>
}
// This preview lives OUTSIDE Composer.Frame but can read composer's state!
function MessagePreview() {
const { state } = use(ComposerContext)
return <Preview message={state.input} attachments={state.attachments} />
}
ForwardButton 和 MessagePreview 在视觉上并不在 composer 的盒子里(一个在对话框底部按钮区,一个是独立预览区),但都能读取 state、调用 actions.submit。姊妹规则 state-lift-state.md 进一步说明了不用这种模式时的三种典型弯路:状态被关在组件内部无法被兄弟组件访问、用 useEffect 把状态同步上传、把 stateRef 通过 props 下钻——这三者都是“状态提升 + provider 边界”要消灭的坏味道。
落地检查清单
把整条规则压缩成可操作的检查项,在 supabase 这类大型 React 代码库(如 apps/studio 下的组件)中重构组件 API 时可逐条对照:
- 拆三段:为每个共享 Context 定义
state(数据)、actions(变更入口)、meta(ref 等非状态引用)三个子类型,再合并为XxxContextValue; - 接口零实现细节:
actions的方法签名只描述行为,不出现useState、store 名等实现词汇;update建议采用函数式更新签名以兼容setState; - Context 初始值为
null:createContext<XxxContextValue | null>(null),让“忘记包 provider”在类型层面暴露; - UI 只认接口:检查组件内是否还有
useXxxState()之类的实现耦合调用,全部替换为use(ComposerContext)形式的接口消费(React 19 项目); - provider 是唯一的实现知情者:状态的具体管理方式(本地 / 全局同步 / 服务端)只允许出现在 provider 内部,与 state-decouple-implementation.md 的要求保持一致;
- 利用 provider 边界而非视觉嵌套:需要共享状态的按钮、预览、对话框操作区,放在 provider 内的任何位置,而不必塞进
Frame。
最后需要说明适用前提:文档示例使用 use() 与 ref-as-prop 写法,属于 React 19+ API;若项目仍在 React 18 或更早版本,应改用 useContext() 并保留 forwardRef 处理 ref 传递(对应 SKILL.md 中对 react19- 类规则的版本限制说明)。至于 meta 中 ref 的独立传递方式,本仓库 Studio 应用的实际代码(如 useFloatingToolbarDrag.ts 中以 React.RefObject 作为独立参数传递)从源码结构看,与这条规则倡导的“把 ref 与状态分离、按需传递”的思路是一致的。
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 StartedRust0625
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