π Data Flow#
Learn how Le Truc components coordinate state. Pass reactive signals from parent to child with pass(), manage dynamic lists, and share values across the component tree with context.
Component Coordination#
Let's consider a product catalog where users can add items to a shopping cart. We have three independent components that work together:
ModuleCatalog(Parent):- Tracks all
SpinButtoncomponents in its subtree and calculates the total count of items in the shopping cart. - Passes that total to a
BasicButton.
- Tracks all
BasicButton(Child):- Displays a badge in the top-right corner when the
badgeproperty is set. - Does not track any state β it simply renders whatever value is passed to it.
- Displays a badge in the top-right corner when the
FormSpinbutton(Child):- Displays an Add to Cart button initially.
- When an item is added, it transforms into a stepper (increment/decrement buttons).
Although BasicButton and FormSpinbutton are completely independent, they need to work together. So ModuleCatalog coordinates the data flow between them.
Parent Component: ModuleCatalog#
The parent component (ModuleCatalog) knows about its children, meaning it can read state from and pass state to them. It uses all() to observe all FormSpinbutton quantities reactively, then pass() to drive the BasicButton's badge and disabled state:
defineComponent('module-catalog', ({ all, first, pass }) => {
const button = first('basic-button', 'Add a button to go to the Shopping Cart')
const spinbuttons = all(
'form-spinbutton',
'Add spinbutton components to calculate sum from.',
)
const total = createMemo(() =>
spinbuttons.get().reduce((sum, item) => sum + item.value, 0),
)
return [
pass(button, {
disabled: () => !total.get(),
badge: () => (total.get() > 0 ? String(total.get()) : ''),
}),
]
})
Whenever any <form-spinbutton> value changes, total updates and the badge reflects the new count β no event listeners or manual wiring needed.
pass() requires a Le Truc child
pass() swaps the child's backing signal directly, so it only works for Le Truc components whose properties are Slot-backed. For any other custom element (Lit, Stencil, plain HTML), drive the child's property reactively with watch(source, bindProperty(el, key)) instead.
Child Component: BasicButton#
The BasicButton component displays a badge when needed β it does not know about any other component nor track state itself. It exposes reactive properties disabled, label, and badge and has effects to keep the DOM subtree in sync with those properties.
defineComponent('basic-button', ({ expose, first, watch }) => {
const button = first('button', 'Add a native button as descendant.')
const label = first('span.label')
const badge = first('span.badge')
expose({
disabled: button.disabled,
label: label?.textContent ?? button.textContent ?? '',
badge: badge?.textContent ?? '',
})
return [
watch('disabled', bindProperty(button, 'disabled')),
label && watch('label', bindText(label)),
badge && watch('badge', bindText(badge)),
]
})
- Whenever the
disabledproperty is updated by a parent component, the button is disabled or enabled. - Whenever the
badgeproperty is updated by a parent component, the badge text updates. - If
badgeis an empty string, the badge indicator is hidden (via CSS).
Child Component: FormSpinbutton#
The FormSpinbutton component reacts to user interactions and exposes a reactive property value of type number. It updates its own internal DOM subtree, but doesn't know about any other component nor where the value is used.
defineComponent('form-spinbutton', ({ all, expose, first, host, on, watch }) => {
const controls = all('button, input:not([disabled])')
const increment = first('button.increment', 'Add a native button to increment the value')
const decrement = first('button.decrement', 'Add a native button to decrement the value')
const input = first('input.value', 'Add a native input to display the value')
const zero = first('.zero')
const other = first('.other')
const nonZero = createMemo(() => host.value !== 0)
const incrementLabel = increment.ariaLabel || 'Increment'
expose({
value: Number.parseInt(input.value) || 0,
max: Number.parseInt(input.max) || 10,
})
return [
on(controls, 'change', (_e, target) => {
if (!(target instanceof HTMLInputElement)) return
const next = Number(target.value)
if (!Number.isInteger(next)) {
target.value = String(host.value)
target.checkValidity()
return
}
const clamped = Math.min(host.max, Math.max(0, next))
if (next !== clamped) {
target.value = String(clamped)
target.checkValidity()
}
host.value = clamped
}),
on(controls, 'click', (_e, el) => {
if (el.classList.contains('decrement')) {
host.value = Math.max(0, host.value - 1)
} else if (el.classList.contains('increment')) {
host.value = Math.min(host.max, host.value + 1)
}
}),
on(controls, 'keydown', (e) => {
const { key } = e
if (['ArrowUp', 'ArrowDown', '-', '+'].includes(key)) {
e.stopPropagation()
e.preventDefault()
const delta = key === 'ArrowDown' || key === '-' ? -1 : 1
host.value = Math.min(host.max, Math.max(0, host.value + delta))
}
}),
watch(nonZero, nz => {
input.hidden = !nz
decrement.hidden = !nz
}),
zero && watch(nonZero, nz => {
zero.hidden = nz
increment.ariaLabel = nz ? incrementLabel : zero.textContent
}),
other && watch(nonZero, bindVisible(other)),
watch(() => String(host.value), bindProperty(input, 'value')),
watch(() => String(host.max), bindProperty(input, 'max')),
watch(() => host.value >= host.max, bindProperty(increment, 'disabled')),
]
})
- Whenever the user clicks a button or presses a handled key, the value property is updated.
- The component sets hidden and disabled states of buttons and updates the text of the
inputelement.
Full Catalog Example#
Here's how everything comes together:
- Each
FormSpinbuttontracks its own value. - The
ModuleCatalogsums all quantities and passes the total toBasicButton. - The
BasicButtondisplays the total if it's greater than zero.
No custom events are needed β state flows naturally!
Shop
Product 1
Product 2
Product 3
ModuleCatalog source code
Loading...
BasicButton source code
Loading...
FormSpinbutton source code
Loading...
Managing Dynamic Lists#
The coordination patterns above assume a fixed set of children. When a list grows and shrinks at runtime, you need a different approach: a reactive list of keyed items holds the data, reconcile() keeps the DOM in sync, and a <template> provides the markup for each item.
The Container, Template, and List#
The component owns a createList() β a reactive ordered collection where each item is a signal and items are identified by stable string keys. The HTML provides a container and an inert template:
defineComponent('module-list', ({ first, host, on, pass }) => {
const form = first('form', 'Add a form element to enter a new list item.')
const textbox = first('form-textbox', 'Add <form-textbox> to enter a new item.')
const submit = first('basic-button.submit', 'Add <basic-button.submit> to add items.')
const container = first('[data-container]', 'Add a container element for items.')
const template = first('template', 'Add a template element for items.')
// Keyed reactive list of plain string items. The 'item' prefix feeds the
// auto-incrementing key generator (item0, item1, ...); stable keys let
// removal target the right item even as the list reorders.
const list = createList([], { keyConfig: 'item' })
return [
reconcile(container, template, list, (element, item) => { /* fill content */ }),
on(form, 'submit', e => { /* add item */ }),
on(host, 'click', e => { /* remove item by delegation */ }),
pass(submit, { disabled: () => !textbox.length }),
]
})
The keyConfig option controls how keys are generated. A string prefix produces auto-incrementing keys ('item' β item0, item1, β¦). A function (item) => string derives the key from item content β required when the same item can reappear and must keep its identity. Without keyConfig, Le Truc falls back to position-based auto-increment.
Reconciling the DOM with reconcile()#
reconcile(container, template, source, bindItem) syncs the source's keys to the container's children in one declarative call. It runs once at connect and again whenever keys are added, removed, or reordered:
reconcile(container, template, list, (element, item) => {
element
.querySelector('slot')
?.replaceWith(document.createTextNode(item.get()))
}),
For every key in source order, the container holds one element stamped with data-key. Entering keys clone the template's single root element and mount bindItem(element, item, key) in its own scope; leaving keys dispose that scope and remove their element; surviving elements are moved with insertBefore() β always reused, never recreated. Per-item value changes flow through the item signal and never trigger structural work.
bindItem does all content work β there is no default fill convention. A returned cleanup runs when the key leaves the list or the component disconnects. It also runs for adopted elements: children already in the container at connect time (server-rendered) are matched to source keys by their data-key attribute and kept; keyed children not in the source and all unkeyed children are removed. So bindItem should be idempotent against server-rendered content β in the example above, an adopted item has no <slot> left to replace, so the fill is naturally a no-op.
Two escape hatches keep reconcile() composable: children carrying data-unreconciled are exempt from reconciliation entirely (never removed, never repositioned β for drag-and-drop markers or server-streamed content arriving mid-interaction), and keyed elements are positioned relative to the keyed subset, not by absolute index, so such unmanaged elements don't drift keyed positions. The sync is strictly one-way, data β DOM: to change the list's structure, mutate the list in an event handler and let reconcile() write the DOM.
bindItem has collector parity with each()'s callback: watch(), on(), pass(), and each() can be called inside it directly, and the collected descriptors activate against that per-item scope rather than the driving structural effect β so an item-level watch(item, β¦) never makes structural work depend on item signals. For static items a one-time fill (as above) is enough; for items whose displayed content depends on signals that change after creation, call watch() inside bindItem instead.
Adding and Removing Items#
Mutations go through the list β never touch the DOM directly. The reconciler reacts to the keys change and updates the container for you.
on(form, 'submit', e => {
e.preventDefault()
const value = textbox.value.trim()
if (!value) return
list.add(value)
textbox.clear() // call a method on the child component
}),
// Event delegation: one handler removes any item whose Remove button was
// clicked, scaling to any number of items without per-item listeners.
on(host, 'click', e => {
const target = e.target as HTMLElement
if (!target.closest('basic-button.remove')) return
const item = target.closest('[data-key]')
if (!(item instanceof HTMLElement)) return
e.stopPropagation()
const key = item.dataset.key
if (key) list.remove(key)
}),
list.add(value) returns the new key; list.remove(key) takes one. textbox.clear() is a method property on the form-textbox child component. pass(submit, { disabled: ... }) drives the submit button's disabled state reactively from the textbox length β the same pass() thread as the rest of this page, without the button knowing anything about the textbox.
Full List Example#
ModuleList source code
Loading...
FormTextbox source code
Loading...
BasicButton source code
Loading...
Providing Context#
Context allows parent components to share state with any descendant components in the DOM tree, without prop drilling. This is perfect for application-wide settings like user preferences, theme data, or authentication state.
Creating Context Keys#
First, define typed context keys for the values you want to share:
// Define context keys with types
export const MEDIA_MOTION = 'media-motion' as Context<
'media-motion',
() => 'no-preference' | 'reduce'
>
export const MEDIA_THEME = 'media-theme' as Context<
'media-theme',
() => 'light' | 'dark'
>
Provider Component#
The provider component creates the shared state inside expose() and calls provideContexts() in the returned effect array. The example below is a simplified excerpt showing two of the four media contexts β see the full source for the complete implementation:
import { createContext, createSensor, defineComponent } from '@zeix/le-truc'
export type ContextMediaProps = {
readonly motion: 'no-preference' | 'reduce'
readonly theme: 'light' | 'dark'
}
declare global {
interface HTMLElementTagNameMap {
'context-media': HTMLElement & ContextMediaProps
}
}
export default defineComponent<ContextMediaProps>(
'context-media',
({ expose, provideContexts }) => {
expose({
motion: createSensor(
set => {
const mql = matchMedia('(prefers-reduced-motion: reduce)')
const listener = (e) => set(e.matches ? 'reduce' : 'no-preference')
mql.addEventListener('change', listener)
return () => mql.removeEventListener('change', listener)
},
{ value: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduce' : 'no-preference' },
),
theme: createSensor(
set => {
const mql = matchMedia('(prefers-color-scheme: dark)')
const listener = (e) => set(e.matches ? 'dark' : 'light')
mql.addEventListener('change', listener)
return () => mql.removeEventListener('change', listener)
},
{ value: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' },
),
})
return [provideContexts(['motion', 'theme'])]
},
)
Usage in HTML#
The provider component wraps your entire application or a section that needs shared state:
<context-media>
<!-- Arbitrarily nested HTML with one or many context consumers -->
<main>
<card-mediaqueries>
<dl>
<dt>Motion Preference:</dt>
<dd class="motion"></dd>
<dt>Theme Preference:</dt>
<dd class="theme"></dd>
</dl>
</card-mediaqueries>
</main>
</context-media>
Consuming Context#
Consumer components use requestContext() to access shared state from ancestor providers. The returned Signal<T> is reactive β when the provider's signal updates, all consumers update automatically. It serves the fallback until a provider answers, and a provider that connects late (bundle ordering, code-splitting) is still picked up β the consumer switches from fallback to the provided value without any extra code.
Consumer Component#
Here's a simple card that displays the current motion and theme preferences:
import { bindText, defineComponent } from '@zeix/le-truc'
import { MEDIA_MOTION, MEDIA_THEME } from '../../context/media/context-media'
export default defineComponent(
'card-mediaqueries',
({ first, requestContext, watch }) => {
const motionEl = first('.motion')
const themeEl = first('.theme')
const motion = requestContext(MEDIA_MOTION, 'unknown')
const theme = requestContext(MEDIA_THEME, 'unknown')
return [
motionEl && watch(motion, bindText(motionEl)),
themeEl && watch(theme, bindText(themeEl)),
]
},
)
Full Context Example#
- Motion Preference:
- Theme Preference:
- Device Viewport:
- Device Orientation:
ContextMedia source code
Loading...
CardMediaqueries source code
Loading...
Async State with Tasks#
When a component needs to load data β fetch a fragment, import a module, run any async work β model it as a Task. A Task is an async derivation that auto-cancels in-flight work when its dependencies change and exposes four states through match(): ok, nil, stale, and err.
Routing precedence is nil > err > stale > ok:
nilfires on the first run, before any value has resolvederrfires when the task rejectsstalefires when the task has a retained value and is recomputing after a dependency change β use it to keep the old content visible while refreshingokfires with the resolved value
The module-lazyload component shows the full pattern. It fetches an HTML fragment, injects it into a content element, and drives separate loading, error, and content views from a single Task:
defineComponent('module-lazyload', ({ expose, first, host, watch }) => {
const callout = first('card-callout', 'Needed to display loading state and error messages.')
const loading = first('.loading', 'Needed to display loading state.')
const errorEl = first('.error', 'Needed to display error messages.')
const contentEl = first('.content', 'Needed to display content.')
const content = createTask(async (_prev, abort) => {
const url = host.src
if (!url) throw new Error('No URL provided')
const response = await fetch(url, { signal: abort })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
})
expose({ src: asString() })
return [
watch(content, {
ok: html => {
loading.hidden = true
contentEl.hidden = false
contentEl.innerHTML = html
},
nil: () => {
loading.hidden = false
contentEl.hidden = true
},
stale: () => {
contentEl.style.setProperty('opacity', 'var(--opacity-dimmed)')
return () => contentEl.style.removeProperty('opacity') // reset on next dispatch
},
err: error => {
loading.hidden = true
errorEl.hidden = false
errorEl.textContent = error.message
contentEl.hidden = true
return () => { errorEl.hidden = true; errorEl.textContent = '' }
},
}),
]
})
The HTML provides all three regions up front; the watch handler toggles their visibility as the Task moves through its states:
<module-lazyload src="./fragments/details.html">
<card-callout>
<p class="loading" role="status">Loadingβ¦</p>
<p class="error" role="alert" aria-live="assertive" hidden></p>
</card-callout>
<div class="content" hidden></div>
</module-lazyload>
Return a cleanup from stale and err handlers
stale and err receive no arguments, but they may return a cleanup function that runs synchronously before the next dispatch. Use it to reset the DOM state you changed β removing a dimming class, clearing the error text β so the next ok or stale run starts clean.
The Task owns the async work
Don't fetch inside a plain watch callback. A Task receives an AbortSignal and is auto-cancelled when its dependencies change, so switching src aborts the in-flight request. Its pending and error states become first-class reactive values that compose through match().
Choosing a Coordination Mechanism#
The coordination patterns above all assume you have already split your UI into components. That decision comes first, and it is a separate question from how the resulting pieces talk to each other.
Split first, then coordinate#
A component should encapsulate a design decision that is likely to change on its own. If two concerns will always change together, keep them in one component β splitting them only creates coupling you then have to bridge. Split when a part could be reused independently or could evolve on a different schedule than the rest.
Inside one component, shared state is just a local signal β a State or Memo created in the factory closure and read by that component's own effects. No coordination mechanism is needed because there is no boundary to cross.
Coordinating across boundaries#
Once a boundary exists, choose the mechanism by the shape of the relationship across it:
| Mechanism | Spans | Coupling | Use when |
|---|---|---|---|
pass() | parent β a specific child | parent names the child | A parent drives a named property on a direct child it already knows about β e.g. summing spinbutton values into a badge on its button |
provideContexts() / requestContext() | ancestor β any descendant | none (decoupled) | Many consumers need the same value and you don't want to know which ones β theme, locale, auth state. Provider and consumer never reference each other by tag name |
Task + match() | component β server / external API | none (async boundary) | The source of truth is outside the page β a fetch, dynamic import, or any async stream. The component coordinates with an external system, not another component |
The first two move state between Le Truc components. A Task coordinates with the world outside the component tree β the server, a network endpoint, an async API. The boundary is different, but the question is the same: how does this component get a value it does not own?