Le Truc 2.6.0

Props & State#

Declare reactive properties with expose(). Parse initial values from server-rendered attributes, hold private state in local signals, expose read-only values and imperative methods. State lives on the host as plain properties.

Declare Reactive State#

Your server already rendered values into the HTML. expose() turns them into state: it declares the component's reactive properties and initializes each one — from an attribute, from the DOM, or from a signal you create yourself. The properties are backed by signals. Signals are reactive values that propagate changes automatically, and they surface as regular JavaScript properties on the component host:

js

console.log('count' in el) // Check if the signal exists
console.log(el.count) // Read the signal value
el.count = 42 // Update the signal value

Signal Types#

Le Truc re-exports the reactive primitives from @zeix/cause-effect. Every signal type participates in the same dependency graph with the same propagation, batching, and cleanup semantics. Pick the type that matches the data's role:

TypeRoleWhen to use it
MutableCellMutable sourceLocal mutable state you read and write inside the component
CellSync or async derivation or external inputValues computed from other signals, content retrieved via async work, and values that arrive from outside the graph
MutableStoreReactive objectAn object whose individual properties are each reactive
MutableListReactive arrayA keyed list with stable item identity across add, remove, sort, and reorder
DerivedListDerived keyed listMap another list per item, or feed one from a fetch or an external stream
EffectSide-effect sinkTerminal consumer for work outside the graph. Inside a component, prefer the factory's watch() / on() over a bare createEffect()

Slot is an integration primitive used internally by pass() to swap a child's backing signal. You rarely create one directly.

Migrating Deprecated Signal Names#

Upcoming Cause & Effect 2.0 will unify its signal types to Cell (was: State, Memo, Task, Sensor), List, Store, and their mutable counterparts. Le Truc re-exports each Cause & Effect replacement name next to its deprecated counterpart, so you can migrate incrementally. See Cause & Effect's MIGRATION-2.0.md for the full rename list and a codemod:

sh

bun tools/codemod-v2.ts 'src/**/*.ts' --module @zeix/le-truc

Signals Are Typed and Non-Nullable#

Signals are statically typed and non-nullable. Effects need no null-checks.

  • With TypeScript, assigning null, undefined, or a wrong type to a signal property is a compile-time error.
  • With vanilla JavaScript, setting a signal to null or undefined throws a NullishSignalValueError at runtime. Type mismatches are not caught.

A watched source can still produce nil at runtime — an async value that has not resolved, or a derivation that hit an error. Each bind* helper defines what happens then; see the nil behavior table on the Events & Effects page.

Parse from Attributes#

The standard way to set initial state is via server-rendered attributes on the component element. Pass a Parser function to expose(). Le Truc calls it with the attribute value at connect time. Bundled parsers cover common types. asParser() wraps any custom parser function. See the API reference for detailed descriptions and usage examples of built-in parsers.

js

defineComponent('my-component', ({ expose }) => {
  expose({
    count: asInteger(), // Bundled parser: Convert '42' -> 42
    date: asParser(v => new Date(v ?? '')), // Custom parser: '2025-12-12' -> Date object
  })
})

Parsers run once at connect time

The attribute value drives the initial signal. Per default, attribute changes after connection do not re-run the parser. Use property writes to update state after connect. To make a Parser-backed prop re-parse on attribute mutations, pass the observedAttributes() extension to defineComponent(). This matters for frameworks like React, which set attributes rather than properties.

Local Signals for Private State#

Not every value belongs to the public interface. State that only this component reads and writes stays in the factory closure — create it with createCell() or deriveCell() and never pass it to expose():

js

defineComponent('my-component', ({ first, on, watch }) => {
  const increment = first('button.increment')
  const count = first('.count')
  const double = first('.double')

  const countCell = createCell(0)
  const doubleCell = deriveCell(() => countCell.get() * 2)

  on(increment, 'click', () => { countCell.update(v => ++v) })
  watch(countCell, bindText(count))
  watch(doubleCell, bindText(double))
})

Outside components cannot access the countCell or doubleCell signals. Expose a value only when a consumer has a reason to read or write it.

Read-Only Properties#

Some values are public information but private control. A text length, a form validity, a loading flag — consumers read them, but only the component writes them. Expose the getter, not the cell:

my-input.tsjs

defineComponent('my-input', ({ expose, first, on }) => {
  const textbox = first('input', 'A textbox is required.')
  const length = createCell(textbox.value.length)

  expose({
    value: textbox.value,
    length: length.get,  // read-only — consumers can read, not set
  })

  on(textbox, 'input', () => {
    length.set(textbox.value.length)
  })
})

A consumer who writes el.length = 5 gets a ReadonlySignalError. That is the contract working. To watch this property inside the same factory, pass the signal directly:

js

watch(length, bindVisible(clearBtn))

Imperative Methods#

Reactive properties cover most of a component's surface. Occasionally a consumer needs a verb — clear(), reset(), focus(). Wrap it in defineMethod():

form-textbox.jsjs

defineComponent('form-textbox', ({ expose, first, host, on, watch }) => {
  const textbox = first('input', 'Add a native input or textarea as descendant.')

  expose({
    value: textbox.value,
    clear: defineMethod(() => {
      host.value = ''
      textbox.value = ''
      textbox.setCustomValidity('')
      textbox.checkValidity()
      textbox.dispatchEvent(new Event('input', { bubbles: true }))
      textbox.dispatchEvent(new Event('change', { bubbles: true }))
      textbox.focus()
    }),
  })

  on(textbox, 'change', () => ({ value: textbox.value }))
  watch('value', bindProperty(textbox, 'value'))
})

Common use cases include reset(), stepUp() / stepDown(), or clear(). The function operates on the host and hides implementation details. You can expose both reactive values (value) and methods (clear) side by side.

Always use defineMethod(), never a plain function

Le Truc identifies method producers by a brand symbol that defineMethod() attaches. Le Truc treats an unbranded function passed to expose() as a thunk instead. This creates a computed reactive property.