# Client component rules
English | [中文](client-components.zh.md)
Rules for client components, their state, and their effects. [packages/client/AGENTS.md](../../packages/client/AGENTS.md) carries the detailed discipline; [slots.md](../subsystems/slots.md) owns the Slot design and [web-styling.md](../web-styling.md) the styling rules. Each section below states the requirement, how a reviewer judges it, where its boundary and exception sit, and a correct and a wrong example.
## State arrives through injection
A Slot's component receives everything it renders through the Injection face bound at its render site — owner props, hook seats, and actions — and never reads one large global state at the top of the tree to hand values down. Keeping every live value on that face is what makes a prop that did not change keep its identity, and what lets a component subscribe only to the sub-state it reads. Without it any unrelated field change re-renders the whole subtree, which is how a long session under streaming events loses frames while the user scrolls or types.
- Judged by: at every `slots.render` site the author must be able to name which sub-states the rendered component subscribes to and which of its incoming props keep a stable identity across renders. Watch the mutability of every prop: a value rebuilt each render — a fresh object, array, or inline callback — propagates re-renders even when nothing in it changed.
- Not covered: the component's own local state (`useState` for a fold, a hover, a draft) is not injection content, and a component that subscribes to nothing outside React needs no new seat.
- Exception: a new injection face or hook seat changes the Slot contract, so that change states its impact — which components subscribe to what — in review; using an existing face needs no per-component approval.
- Fix: narrow the injection to what the view renders, expose one field per read through a hook seat, and pass a narrow action set instead of a store or service handle. [Component inputs](../subsystems/slots.md#component-inputs) owns the inject factory, the standard hook seats, and how each becomes a prop.
The render site injects one hook seat and a narrow action set, and the component reads only the field it renders:
```tsx
// Render site: plain owner data, one hook seat, a narrow action set.
slots.render('sidebar.right', {
props: { id },
useHooks: SidebarHooks,
action: { rename, archive },
})
function SidebarRight({ id, useInfo, action }: Props) {
const { title } = useInfo() // subscribes to title only
return
}
```
The top-level component reads the whole view store and hands it down as one prop, so the render site can no longer answer either question above:
```tsx
function Sidebar() {
const view = useViewStore() // one top-level global read
return // the whole store travels down
}
function SidebarRight({ view }: { view: ViewStore }) {
return {view.activePanel.title}
}
```
Every field change in that store re-renders the whole sidebar subtree, and the component's subscription set is invisible to the site that bound it.
## Props carry data and callbacks, not React nodes
A prop is data or a callback; a React node travels through a declared slot. Passing an element as a prop value or an injected member hides a second composition path that the Slot contract does not describe, moves render control from the registration surface to the call site, and lets one package assemble another package's markup. New node-valued props or injected members are never added; a location that needs host-supplied markup is declared as a child slot instead.
- Judged by: `ReactNode`, `ReactElement`, or a `() => JSX.Element` in a component's props type, in an inject factory's return value, or in an owner-props object at a render site.
- Not covered: node-valued fields that already shipped keep working. The rule triggers when a change touches one, and that change converts it rather than extending it.
- Exception: when the conversion cannot happen in the same change, the owning domain expert confirms the deferral and the change records that confirmation; the author never self-approves.
- Fix: declare the location in the parent registration's `children`, render it with `renderSlot`, and pass data through the child's props. [Extension rules](../subsystems/slots.md#extension-rules) own which package may declare and render which slot.
The header location is a declared child slot, so the host passes data and the registered component owns its markup:
```tsx
// The card declares the header location; the host passes data only.
ctx.slots.register(
{
name: 'tool.call.toolview',
children: { header: { kind: 'single', scope: 'session' } },
},
ToolCard,
)
function ToolCard({ title, renderSlot }: Props) {
return {renderSlot('header', { title })}
}
```
A React node crosses the boundary as a prop value, so the composition path it travels is not declared anywhere:
```tsx
type ToolCardProps = {
header?: React.ReactNode
footer?: React.ReactNode
}
// The call site assembles another package's markup.
} />
```
The card's Slot contract stops describing what it renders, the call site keeps render control, and the badge's package becomes a runtime reference from whoever passes it.
## A slot registration is a stable function component
The value registered into a slot is a stable function component declared at module scope, or a plain string when the seat renders fixed text. A component function created during render is a new component type on every pass, and React treats a changed type as a different component: it unmounts the subtree and mounts a fresh one, which resets expanded rows, input focus, and scroll position. Registration-time parameters reach the component through props rather than by closing over render-time variables.
- Judged by: where the registered value is created. An inline arrow in the registration call, a component defined inside a render body, and a `useCallback` wrapper around an inline component all fail; a module-scope `function` declaration or a string passes.
- Not covered: the rule constrains the identity of the registered value, not its complexity. A stable component may still read injected props, hooks, and callbacks, and a fixed-text seat may stay a string.
- Exception: creating a component during render is correct only when the caller wants the remount, and an ordinary change needs no separate approval; a change to the form of the Slot contract follows the injection-face review described under State arrives through injection.
- Fix: move the component to module scope, type its props from the registration's shares, and pass the varying value through the inject face instead of the closure.
The registration holds one stable reference to a module-scope component, and the varying value arrives injected:
```tsx
function PanelTitle({ title, count }: Props) {
return {title} ({count})
}
// The registration holds one stable reference to the module-scope component.
ctx.slots.registerFactory(
{
name: 'panel',
slots: { title: PanelTitle },
inject: () => ({ title: 'Sessions', count: 3 }),
},
Panel,
)
```
An inline arrow inside the registration call creates a new component type whenever the registration runs:
```tsx
// A new component type each time this registration runs.
ctx.slots.registerFactory(
{ name: 'panel', slots: { title: (p) => {p.title} } },
Panel,
)
```
React sees a different type than the previous registration, remounts the seat, and resets its local state — expanded, focused, or scrolled position — on an otherwise unrelated update.
## Components never receive a Cordis context
`ctx` belongs to the apply world: the plugin body and the `inject` factories closed over it. A component never receives a Cordis context, never imports a service class to poke it, and never reads a React context, because business components see zero contexts — the renderer's binding contexts are internal to it. A bare observable reaches a component only after Injection wraps it in a hook; a component that subscribes directly owns a lifecycle that nothing on the Injection face releases.
- Judged by: `ctx` or `Context` in a component's parameter or props type; a component body reading `observable.value`, calling `subscribe`, or wiring `useSyncExternalStore`; a service class imported into a feature `.tsx` module.
- Not covered: a component-internal behavioral hook that subscribes to nothing outside React (a timer, a media query) is not injection content and stays in the component.
- Exception: none. The direct subscription still present in `TabSlot` is remediation work owned by that entry's owner, not a precedent for new code.
- Fix: move the fact into the registration's `inject` — plain data and callbacks, with bare observables in the reserved `hooks` compartment — or into a declared store, and read it through the derived seat.
Injection binds the observable, and the component reads one derived hook whose subscription the renderer owns:
```tsx
// The inject factory is the only place that closes over ctx.
const inject = () => ({
hooks: { tab: tabStore.observable },
action: { openTab },
})
function TabView({ useTab, action }: Props) {
const { loaded } = useTab() // the renderer owns the subscription
return