首页
/ Supabase 仓库实践:用 state/actions/meta 三段式通用 Context 接口实现 UI 状态依赖注入

Supabase 仓库实践:用 state/actions/meta 三段式通用 Context 接口实现 UI 状态依赖注入

2026-09-06 22:28:09作者:余洋婵Anita

本文基于 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)”。它与同目录下的两条姊妹规则构成一条完整链路:

文档给出的核心原则一句话概括: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)

三个设计点值得注意:

  1. 接口本身不含任何实现细节ComposerState 只描述“有哪些数据字段”,不关心它来自 useState、外部 store 还是服务端同步;
  2. actions.update 采用函数式更新签名 (updater: (state) => state) => void,这与 useStatesetState 语义天然兼容,使得 useState 可以直接作为 update 传入(见下文 Provider A);同时也不排斥换成 Redux/Zustand 风格的更新函数;
  3. 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} />
}

ForwardButtonMessagePreview 在视觉上并不在 composer 的盒子里(一个在对话框底部按钮区,一个是独立预览区),但都能读取 state、调用 actions.submit。姊妹规则 state-lift-state.md 进一步说明了不用这种模式时的三种典型弯路:状态被关在组件内部无法被兄弟组件访问、用 useEffect 把状态同步上传、把 stateRef 通过 props 下钻——这三者都是“状态提升 + provider 边界”要消灭的坏味道。

落地检查清单

把整条规则压缩成可操作的检查项,在 supabase 这类大型 React 代码库(如 apps/studio 下的组件)中重构组件 API 时可逐条对照:

  1. 拆三段:为每个共享 Context 定义 state(数据)、actions(变更入口)、meta(ref 等非状态引用)三个子类型,再合并为 XxxContextValue
  2. 接口零实现细节actions 的方法签名只描述行为,不出现 useState、store 名等实现词汇;update 建议采用函数式更新签名以兼容 setState
  3. Context 初始值为 nullcreateContext<XxxContextValue | null>(null),让“忘记包 provider”在类型层面暴露;
  4. UI 只认接口:检查组件内是否还有 useXxxState() 之类的实现耦合调用,全部替换为 use(ComposerContext) 形式的接口消费(React 19 项目);
  5. provider 是唯一的实现知情者:状态的具体管理方式(本地 / 全局同步 / 服务端)只允许出现在 provider 内部,与 state-decouple-implementation.md 的要求保持一致;
  6. 利用 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 与状态分离、按需传递”的思路是一致的。

登录后查看全文
热门项目推荐
相关项目推荐