ReviewOS

stacks/stx

components: parent signals don't drive child state — every `@stacksjs/components` component is effectively uncontrolled (`<Dialog open=`, `<TextInput value=`, etc.)

#1704
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

TL;DR

@stacksjs/components's components all capture their props at template-eval (server) time and initialize a local state(...) signal from them. After that, the component owns its own state internally — there's no path for a parent signal to drive it. So the canonical Vue/React-style <Dialog :open="modalOpen()"> doesn't actually react to modalOpen.set(true). To control the component you have to grab a ref and call defineExpose'd methods.

This makes every component effectively uncontrolled, which collides head-on with the rest of stx's signal-based architecture.

What the pattern looks like in the components

packages/components/src/ui/dialog/Dialog.stx:

<script server>
export const open = $props.open || false
</script>

<script client>
const isOpen = state({{ open }})   // ← initial value captured server-side
function close() { isOpen.set(false); emit('close') }
defineExpose({ open: () => isOpen.set(true), close, isOpen })
</script>

<div :show="isOpen()" ...>
  <slot />
</div>

packages/components/src/ui/input/TextInput.stx, same shape:

<script server>
export const value = $props.value || ''
</script>

<script client>
const inputValue = state({{ value }})   // ← captured once
function onInput(event) { inputValue.set(event.target.value); emit('input', inputValue()) }

Every input, Switch, Checkbox, Radio, Select, Notification — same pattern. The prop is a one-shot initial value.

What the consuming code wants to write

<script client>
  const modalOpen = state(false)
  const email = state('')
</script>

<button @click="modalOpen.set(true)">Open</button>
<Dialog :open="modalOpen()" @close="modalOpen.set(false)">
  ...
</Dialog>

<TextInput :value="email()" @input="email.set($event.detail)" />

This compiles and renders fine on the first paint. But:

  • modalOpen.set(true) from anywhere else does nothing to the Dialog (its isOpen is a separate signal and was initialized from false)
  • email.set('foo@bar') from a "reset" button doesn't clear the visible input
  • The parent has no way to know when the Dialog actually opened/closed except by listening to @close

The workaround that actually works

<script client>
  const dialogRef = ref()
  function openModal() { dialogRef.value.open() }
  function closeModal() { dialogRef.value.close() }
</script>

<button @click="openModal()">Open</button>
<Dialog x-ref="dialogRef">...</Dialog>

That's MORE boilerplate than the hand-rolled version (<div :show="modalOpen">…</div>), so unsurprisingly nobody does it.

Adoption signal

Census of <Dialog>, <TextInput>, <EmailInput>, <PasswordInput>, <NumberInput>, <Select>, <Switch>, <Checkbox> usage across bench-review, paweldregan, trailbuddy, training:

0 uses of <Dialog>
0 uses of <TextInput>
0 uses of <EmailInput>
0 uses of <PasswordInput>
0 uses of <NumberInput>
0 uses of <Select>
0 uses of <Switch>
0 uses of <Checkbox>

The team writes <div :show="modalOpen"> + raw <input x-model="email"> everywhere instead. The components ship but the apps can't use them.

Proposed fix

Two options, not mutually exclusive:

Option A — effect() on the prop to sync down. Inside each component's <script client>, add an effect that watches the parent's prop expression and updates the local state. Something like:

<script client>
const isOpen = state({{ open }})
// Resync from parent when the prop changes (e.g. parent-driven :open binding).
effect(() => {
  const fromProp = {{ open }}  // re-evaluated on each parent signal change
  if (fromProp !== isOpen()) isOpen.set(fromProp)
})
</script>

For this to actually work, the template-substitution layer needs to make re-evaluate when the parent prop is a signal call. That may require framework-level support (a "live prop" mechanism distinct from one-shot SSR substitution).

Option B — accept signals directly as props. When the parent writes <Dialog :open="modalOpen"> (no parens — passing the signal, not its value), the component receives the signal object and uses it as its local state directly:

<script client>
const isOpen = {{ open }}._isSignal ? {{ open }} : state({{ open }})
</script>

Then modalOpen.set(true) immediately drives the Dialog because they're the same signal. This is the cleanest model and matches how stores work today.

Either fix lets the components actually be controlled by their parents, which is what every consumer expects.

Why this matters

CLAUDE.md § "Reach for stx primitives before vanilla JS" tells agents to use the components. The components don't work the way agents expect (and the way the rest of stx works). The result: the recommendation gets ignored, hand-rolled components proliferate, and the components package becomes shelfware.

Related: #1703 (Tabs/Accordion content-as-string)

🤖 Filed via Claude Code

Sign in to comment on this issue.