Le Truc Docs 2.3.0

🏗️ Components#

Create lightweight, self-contained Web Components with built-in reactivity. Le Truc lets you define custom elements that manage state efficiently, update the DOM automatically, and enhance server-rendered pages without an SPA framework.

Defining a Component#

Le Truc builds on Web Components, extending HTMLElement to provide built-in state management and reactive updates.

Le Truc enhances HTML — it doesn't replace it

A Le Truc component wraps existing server-rendered content. The HTML inside the custom element is the starting point — visible before JavaScript runs. See Progressive Enhancement for how this works.

Components are created with the defineComponent() function, which takes a valid custom element tag name (two or more words joined with -), a factory function, and an optional array of extensions:

js

defineComponent('my-component', ({ expose, first, all, watch, on }) => {
  // Query descendant elements
  const el = first('selector')
  // Declare reactive properties
  expose({ /* ... */ })
  // Call watch(), on(), each(), pass(), or provideContexts()
  watch(/* source */, /* handler */)
  on(el, /* type */, /* handler */)
})

The factory receives a FactoryContext with helpers for querying descendant elements, declaring reactive properties, and registering effects — each covered in the sections below. The optional third argument augments the component with opt-in capabilities like form participation or attribute-driven reactivity; each bundled extension is tree-shaken away unless imported and used.

Explicit return still works, but is deprecated

watch(), on(), each(), pass(), and provideContexts() register their effects automatically when called — no return needed. Returning a FactoryResult array of the same descriptors (return [watch(...), on(...)]) still works for backward compatibility but is deprecated and will be removed in the next major version. See ADR 0018.

Declare props with type, not interface

defineComponent<P> constrains P to ComponentProps, an indexed record. TypeScript infers an index signature for object type literals (type FooProps = { … }) but never for interfaces, since interfaces can be declaration-merged. Always declare component props with type — an interface won't compile against the constraint.

Using the Custom Element in HTML#

Once registered, the component can be used like any native HTML element:

html

<my-component>Content goes here</my-component>

Component Lifecycle#

Le Truc manages the Web Component lifecycle from creation to removal. Here's what happens.

Connected to the DOM#

The factory function runs inside connectedCallback(). Element queries, expose(), and the registered effects all execute at this point — the factory is the component's setup phase, not its constructor. If the component disconnects and reconnects, the factory runs again with a fresh closure. See Managing State with Signals for the ways to initialize reactive properties.

Disconnected from the DOM#

In the disconnectedCallback() Le Truc runs all cleanup functions returned by effects during the setup phase in connectedCallback(). This will remove all event listeners and unsubscribe all signals the component is subscribed to, so you don't need to worry about memory leaks.

If you subscribe to external APIs that live outside the component's reactive scope — a native IntersectionObserver, ResizeObserver, or similar — wrap the setup and its cleanup in a hand-authored EffectDescriptor and register it with watch(() => true, …):

js

defineComponent('my-component', ({ host, watch }) => {
  watch(() => true, () => {
    // Setup logic
    const observer = new IntersectionObserver(([entry]) => {
      // Do something
    })
    observer.observe(host)

    // Cleanup logic
    return () => observer.disconnect()
  })
})

() => true has no signal dependency, so this effect runs its setup exactly once, on connect — watch() registers the descriptor's returned cleanup the same way it does for a normal reactive source.

A returned cleanup only runs if it's registered

Returning the descriptor from the factory (return [() => { ...; return cleanup }]) still works, but a bare thunk you neither return nor pass to a helper never runs its cleanup — there's no path for disconnectedCallback() to find it. watch(() => true, descriptor) is the direct replacement for return here, and explicit return is deprecated as of v3.0 alongside the other helpers' explicit-return form.

Managing State with Signals#

Le Truc manages state using signals — reactive values that propagate changes automatically. Signals are exposed 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. Use the type that matches the data's role:

TypeRoleWhen to use it
StateMutable sourceLocal mutable state you read and write inside the component
SensorExternal input (lazy)Values that arrive from outside the graph — matchMedia, IntersectionObserver, geolocation. Seeds an initial value via { value }
MemoSync derivationA value computed from other signals — e.g. the sum of a spinbutton collection. For cheap one-off derivations, a plain thunk passed to watch() is often enough
TaskAsync derivationfetch, dynamic imports, or any async work. Auto-cancels in-flight work when its dependencies change and exposes pending / error / ok states via match()
StoreReactive objectAn object whose individual properties are each reactive
ListReactive arrayA keyed collection with stable item identity across add, remove, sort, and reorder
CollectionReactive collectionExternally-driven streams (WebSocket, SSE) or derived pipelines
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.

Characteristics and Special Values#

Signals are statically typed and non-nullable — no null-checks needed inside effects.

  • 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.

When a watch() reactive source produces null or undefined, the nil branch of SingleMatchHandlers fires if present:

  • bindAttribute(el, name) nil branch: calls el.removeAttribute(name) — removes the attribute entirely
  • bindStyle(el, prop) nil branch: calls el.style.removeProperty(prop) — restores the CSS cascade value
  • Plain function handlers (bindText, bindProperty, bindClass, bindVisible) have no nil branch — a nil source leaves the DOM unchanged

Initializing State 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.

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. Attribute changes after connection do not re-run the parser — use event handlers or direct property writes to update state post-connect. To make a Parser-backed prop re-parse on attribute mutations (e.g. for frameworks like React that set attributes rather than properties), pass the observedAttributes() extension to defineComponent().

Bundled Attribute Parsers#

Le Truc provides several built-in parsers for common attribute types. See the Parsers section in the API reference for detailed descriptions and usage examples.

Selecting Elements#

Use the provided selector utilities to find descendant elements within your component:

first()#

Selects the first matching element:

js

defineComponent('basic-counter', ({ expose, first, host, on, watch }) => {
  const increment = first(
    'button',
    'Add a native button element to increment the count.',
  )
  const count = first('span', 'Add a span to display the count.')
  // ...
})

all()#

Selects all matching elements as a Memo<E[]>:

js

defineComponent('module-tabgroup', ({ all, expose, on, watch }) => {
  const tabs = all(
    'button[role="tab"]',
    'At least 2 tabs as children of a <[role="tablist"]> element are needed. Each tab must reference a unique id of a <[role="tabpanel"]> element.',
  )
  const panels = all(
    '[role="tabpanel"]',
    'At least 2 tabpanels are needed. Each tabpanel must have a unique id.',
  )
  // ...
})

Without a hint string (second argument), first() returns undefined if no match is found and effects for that key are silently skipped. With a hint string, first() throws a MissingElementError if the element is missing — use this when the element is truly required for the component to function.

The all() function returns a Memo<E[]> — a memoized, reactive signal of all elements matching the selector. Call .get() to unwrap the current array. Because it's reactive, effects that read from it automatically re-run whenever matching elements are added, removed, or rearranged in the DOM. A malformed selector throws InvalidSelectorError immediately, at the all() call site.

If a queried element is a custom element that has not been defined yet, Le Truc waits up to 200 ms for it to be defined before running effects. This ensures child components are always ready before parent effects activate.

all() observes structural changes and re-runs effects accordingly. Prefer first() when targeting a single element known to be present at connection time.

Adding Event Listeners#

Event listeners respond to user interactions. They are the main cause for changes in component state.

on() — Event Handling#

on(target, type, handler) is called from the factory context with an explicit target element or Memo<E[]> collection:

js

defineComponent('my-component', ({ all, expose, first, host, on }) => {
  const buttons = all('button')
  const input = first('input')

  expose({ active: 0, value: '' })

  on(buttons, 'click', (_e, target) => {
    // Set 'active' signal to value of data-index attribute of button
    const index = parseInt(target.dataset.index ?? '0', 10)
    host.active = Number.isInteger(index) ? index : 0
  })
  // Set 'value' signal to value of input element
  on(input, 'change', () => ({ value: input.value }))
})

The handler receives (event, element) — for Memo targets, element is the matched item from the collection. The handler can also return an object to batch-update multiple host properties at once:

js

on(button, 'click', () => ({
  count: host.count + 1,
  lastClicked: Date.now(),
}))

on() returns an EffectDescriptor that is activated inside a reactive scope, so event listeners are automatically removed when the component disconnects.

Read-Only Event-Driven Properties#

To expose a property that consumers can read but never directly set, create a State in the factory closure and expose only its getter. The on() handler updates the value:

my-input.tsjs

defineComponent('my-input', ({ expose, first, on }) => {
  const textbox = first('input', 'A textbox is required.')
  const length = createState(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)
  })
})

Exposing state.get rather than the full State is what makes the property read-only. When watching this property inside the same factory, pass the signal directly instead of a string prop name — it skips the host slot lookup:

js

watch(length, bindVisible(clearBtn))

Exposing Imperative Methods#

Not every property is a value you read or watch. Some are commands — functions a consumer calls imperatively from event handlers, like reset(), stepUp() / stepDown(), or clear(). Wrap the function in defineMethod() and pass it to expose():

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.focus()
    }),
  })

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

defineMethod() brands the function so Le Truc installs it as a callable method on the host. Use methods if you need to expose a function to other components that operates on the host while hiding 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 attached by defineMethod(). An unbranded function passed to expose() is treated as a thunk instead, creating a computed reactive property.

Synchronizing State with Effects#

Effects automatically update the DOM when signals change, avoiding manual DOM manipulation.

Applying Effects#

watch(), on(), each(), pass(), and provideContexts() each produce an EffectDescriptor and register it automatically when called — no return needed. A hand-authored descriptor you write by hand instead of using one of these five is registered the same way, via watch(() => true, descriptor) — see Disconnected from the DOM for when you need it. The watch(source, handler) helper drives a DOM update from a declared reactive source:

js

watch('open', bindAttribute(host, 'open')) // set attribute from 'open' signal
watch('count', bindText(count))            // update text from 'count' signal
watch('isEven', bindClass(count, 'even'))  // toggle class from 'isEven' signal

The order of calls does not matter.

CSS must define what the class or attribute does

bindClass(el, 'even') adds or removes the even class — but nothing changes visually unless your CSS has a rule for &.even { ... }. The same applies to bindAttribute(): a [aria-selected="true"] selector in CSS only activates when the attribute is present on the element.

See Reactive Styles for examples of how CSS and effects work together.

Per-element Effects with each()#

When you have a Memo<E[]> collection and need different effects for each element — not just one delegated listener — use each(memo, callback). It creates a per-element reactive scope: effects activate when elements enter the collection and are disposed when they leave.

js

defineComponent('module-carousel', ({ all, expose, host, watch }) => {
  const dots = all('button[role="tab"]')

  expose({ index: 0 })

  each(dots, dot =>
    watch(
      () => dot.dataset.index === String(host.index),
      selected => {
        dot.ariaSelected = String(selected)
        dot.tabIndex = selected ? 0 : -1
      },
    ),
  )
})

The callback receives a single element and returns either a single EffectDescriptor or a FactoryResult array — or it can call watch(), on(), or a nested each() directly, the same as the factory itself.

each() vs on() with a Memo target

Use on(memo, type, handler) when a single delegated listener on the host is enough — one click handler for all tabs, for example. Use each(memo, callback) when you need per-element reactive effects that depend on both the element and a signal — like updating ariaSelected on every dot when the selected index changes.

each() nests to any depth

each() callbacks can call another each() — for a grid, rows containing columns containing cells — with no limit on depth. Ordinary inline arrow handlers work at any nesting level. If watch() reports a confusing "no overload matches" error, the usual cause is a handler body that returns a value instead of void (e.g. a one-line array.push(...)).

DOM Binding Helpers#

Le Truc provides bind* helpers for common DOM update patterns. Each returns a handler (or SingleMatchHandlers object) to pass to watch(). See the Helpers section in the API reference for descriptions and usage examples.

Using Local Signals for Private State#

Local signals are useful for state that should not be exposed outside the component. Create them in the factory closure:

js

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

  const countState = createState(0)
  const doubleState = createMemo(() => countState.get() * 2)

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

Outside components cannot access the countState or doubleState signals.

Using Functions for Ad-hoc Derived State#

Instead of a named signal, you can pass a thunk as the watch source to derive a value inline:

js

defineComponent('my-component', ({ expose, first, host, watch }) => {
  const count = first('.count')
  const double = first('.double')

  expose({ count: 0 })

  watch(() => !(host.count % 2), bindClass(count, 'even'))
  watch(() => String(host.count * 2), bindText(double))
})

When to use

  • Use a property name or a local signal when the state is part of the component's public interface or internally reused.
  • Use a thunk when the derived value is only needed in this one place.

Bidirectional Binding with Native Elements#

Some native elements — checkboxes, text inputs, selects — hold state in JS properties that are not reflected by HTML attributes at runtime. input.checked and input.value are the canonical examples: the attribute only sets the initial state, but the property tracks the live state. To keep a signal in sync with a native element, you need to both read from it and write back to it.

The form-checkbox component shows this pattern in full:

js

defineComponent('form-checkbox', ({ expose, first, host, on, watch }) => {
  const checkbox = first('input[type="checkbox"]', 'Add a native checkbox.')

  expose({
    // Read initial checked state from the DOM property, not the attribute
    checked: checkbox.checked,
  })

  // Capture user interaction → update signal
  on(checkbox, 'change', () => ({ checked: checkbox.checked }))
  // Sync signal → drive native element property
  watch('checked', bindProperty(checkbox, 'checked'))
})

Three pieces work together:

  1. checkbox.checked — initializes checked from the DOM property at setup time, picking up any server-rendered or pre-set state.
  2. on(checkbox, 'change', ...) — returns { checked: checkbox.checked } to update the signal when the user interacts with the checkbox.
  3. watch('checked', ...) — drives checkbox.checked = value whenever the signal changes, including when a parent component sets host.checked programmatically.

This creates a full cycle: DOM → signal → DOM, with the signal as the single source of truth.

`bindProperty()` vs `bindAttribute()`

bindAttribute(el, 'checked') sets the HTML attribute, which only controls the checkbox's default state and has no effect on the live .checked property once the page has loaded. bindProperty(el, 'checked') assigns to the element's JS property directly — the only reliable way to update native form element state at runtime.

Use bindProperty() for properties that diverge from their attribute equivalent: checked, value, disabled, readOnly, selectedIndex, ariaLabel, ariaExpanded, ariaDisabled.

Extensions#

The third argument to defineComponent() is an optional array of extensions — small, tree-shakable modules that augment a component with opt-in capabilities without bloating the core. component.ts never statically imports feature-specific code, so a consumer who never calls an extension never bundles it.

Each extension implements the ComponentExtension interface: a name, a set of staticProps to install on the generated class (e.g. static formAssociated = true), observedAttributes and reservedMembers it contributes, and optional lifecycle hooks (installOnPrototype, onConnect, onAttributeChanged). defineComponent() folds the array once at class-definition time. staticProps collisions throw ExtensionCollisionError in dev mode (first declaration wins in production); observedAttributes and reservedMembers are unions across all extensions.

js

defineComponent('my-element', factory, [formAssociated()])

Le Truc ships three extensions, each imported separately:

ExtensionPurpose
formAssociated()Form participation via ElementInternals — value sync, reset, state restore, disabled, native-parity host contract
formAssociatedCheckbox()Form participation keyed on a checked: boolean prop — submits nothing when unchecked
observedAttributes()Re-parses Parser-backed props when their attribute mutates after connect

Form Association#

The formAssociated() extension adapts a component to the form-associated custom element convention. Pass it as the first element of the extensions array, and the factory's context widens to expose the internals object alongside the usual helpers:

form-textbox.jsjs

defineComponent<FormTextboxProps>(
  'form-textbox',
  ({ expose, first, host, internals, on, watch }) => {
    const textbox = first('input, textarea')

    expose({ value: textbox.value })

    // Typed validity flags via the internals escape hatch
    watch(
      () => ({ value: host.value, max: host.maxLength }),
      ({ value, max }) => {
        internals?.setValidity(
          { tooLong: value.length > max },
          value.length > max ? `Max ${max} characters` : '',
        )
      },
    )
  },
  [formAssociated()],
)

With [formAssociated()], Le Truc manages form value sync, reset, state restore, and a <fieldset disabled>-aware disabled property for you. The host gains a native-parity contract delegating to internalsform, name, labels, validity, validationMessage, willValidate, checkValidity(), reportValidity(), setCustomValidity() — so external consumers read them as on a native input. The convention requires a reactive value property; expose it and sync it to the underlying native control as usual. expose() throws InvalidPropertyNameError for any reserved member name managed by the extension.

The internals object on the context (null only if attachInternals() failed) is the escape hatch for typed validity flags and custom :state() pseudo-classes. The rule: use internals?.setFormValue() indirectly through the managed convention (set value, it syncs), but call internals?.setValidity() directly when you need flags beyond a simple custom-error message.

Checkbox-Shaped Controls#

A checkbox's primary state is checked: boolean, and it submits nothing when unchecked — different from formAssociated()'s always-on string value. The formAssociatedCheckbox() extension handles this shape. It shares the same host contract and disabled management as formAssociated(), but the value-sync, reset, and state-restore mechanics target a checked prop instead of value:

form-checkbox.jsjs

defineComponent<FormCheckboxProps>(
  'form-checkbox',
  ({ expose, first, on, watch }) => {
    const checkbox = first('input[type="checkbox"]')

    expose({ checked: asBoolean() })

    on(checkbox, 'change', () => ({ checked: checkbox.checked }))
    watch('checked', bindProperty(checkbox, 'checked'))
  },
  [formAssociatedCheckbox()],
)

internals.setFormValue() receives the host's own value attribute when checked (default 'on', matching native <input type="checkbox">) and null when unchecked. The convention requires a reactive checked property.

Do not combine the two form extensions

Both formAssociated() and formAssociatedCheckbox() declare the same staticProps.formAssociated key. Combining them on one component throws ExtensionCollisionError in dev mode. Radio groups and listboxes don't need formAssociatedCheckbox() — their selection aggregates into one string value on the container, which fits formAssociated().

Attribute-Driven Reactivity#

Properties are the primary reactive interface. By design, a Parser passed to expose() reads its attribute once, at connect time — attribute changes after connect do not re-run it. The observedAttributes() extension is the opt-in escape hatch for when you need the parser to fire again on later attribute mutations. This matters chiefly for frameworks like React that set DOM attributes on custom elements rather than properties:

basic-gauge.jsjs

defineComponent<BasicGaugeProps>(
  'basic-gauge',
  ({ expose, first, host, watch }) => {
    expose({ value: asNumber() })

    watch('value', v => { /* update the gauge */ })
  },
  [observedAttributes(['value'])],
)

Named attributes are added to the class's static observedAttributes. On each mutation, the extension re-runs the same retained Parser against the attribute's new string value and writes the result to the prop. Props whose initializer is not a branded Parser are left untouched. Use this sparingly — for most components, event handlers or direct property writes are the right way to update state post-connect.