🔄 Data Flow#
Learn how Le Truc components coordinate state. Pass reactive signals from parent to child with pass(), expose callable methods with defineMethod(), 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() works with Le Truc components only
pass() replaces the backing signal of the child's reactive property directly — this only works for Le Truc components whose properties are Slot-backed. For non-Le Truc custom elements (Lit, Stencil, FAST, etc.) or plain HTML elements, use watch(source, bindProperty(el, key)) instead. bindProperty assigns to the element's public JS setter and works correctly regardless of the child's internal framework.
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 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 component coordination patterns above work with a fixed set of children. When your list grows and shrinks at runtime, you need a different approach: a container element where items live, a <template> for the item markup, and imperative methods on the host to add and remove items.
Exposing Methods#
Not every component property is a reactive signal. When a property represents a command — something you call rather than something you observe — use defineMethod(). Pass it directly to expose() with the callable function as the argument:
defineComponent('module-list', ({ expose, first }) => {
const container = first('[data-container]', 'Add a container element for items.')
const template = first('template', 'Add a template element for items.')
let addKey = 0
expose({
add: defineMethod((process) => {
const item = template.content.cloneNode(true).firstElementChild
if (item instanceof HTMLElement) {
item.dataset.key = String(addKey++) // stable identity for removal
if (process) process(item) // optional post-processing before insert
container.append(item)
}
}),
delete: defineMethod((key) => {
container.querySelector(`[data-key="${key}"]`)?.remove()
}),
})
// ...
})
The function passed to defineMethod() IS the callable method — host.add and host.delete will be that function. The container, template, and addKey references come from the factory closure. After connect, callers can use host.add() and host.delete(key) imperatively.
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. The same rule applies to custom parsers: always use asParser().
HTML Structure#
The component needs a container and a template:
<module-list>
<ul data-container></ul>
<template>
<li>
<span><slot></slot></span>
<basic-button class="delete">
<button type="button">Remove</button>
</basic-button>
</li>
</template>
</module-list>
Items already present in the container on first render are preserved. The <template> element is inert — its content is only cloned when host.add() is called.
Handling Deletion by Event Delegation#
Rather than attaching a listener to each delete button, use event delegation on the host: one on(host, 'click', ...) handler checks whether the click reached a delete button, then removes the closest keyed ancestor:
on(host, 'click', e => {
const target = e.target
if (target instanceof HTMLElement && target.closest('basic-button.delete')) {
e.stopPropagation()
target.closest('[data-key]')?.remove()
}
})
This scales to any number of items and works for items added after setup — no re-binding needed.
Coordinating Child Components#
module-list also coordinates with a form-textbox and an add basic-button. When the form is submitted, it reads the textbox value, adds the item, then clears the input. The add button is disabled when the textbox is empty or the item limit is reached:
({ expose, first, host, on, pass }) => {
const container = first('[data-container]', 'Add a container element for items.')
const template = first('template', 'Add a template element for items.')
const form = first('form')
const textbox = first('form-textbox')
const add = first('basic-button.add')
const max = asInteger(1000)(host.getAttribute('max'))
// ... expose({ add: defineMethod(...), delete: defineMethod(...) })
return [
form && on(form, 'submit', e => {
e.preventDefault()
const content = textbox?.value
if (content) {
host.add(item => {
item.querySelector('slot')?.replaceWith(content) // fill template slot
})
textbox.clear() // call method on child component
}
}),
add && pass(add, {
disabled: () =>
(textbox && !textbox.length) || container.children.length >= max,
}),
on(host, 'click', e => { /* delegation for delete */ }),
]
}
textbox.clear() is itself a method property on form-textbox — the same defineMethod() pattern in a child component. pass() drives the button's disabled state reactively from two conditions without the button knowing anything about either.
Full 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:
export type ContextMediaProps = {
readonly 'media-motion': 'no-preference' | 'reduce'
readonly 'media-theme': 'light' | 'dark'
}
declare global {
interface HTMLElementTagNameMap {
'context-media': HTMLElement & ContextMediaProps
}
}
export default defineComponent<ContextMediaProps>(
'context-media',
({ expose, provideContexts }) => {
expose({
[MEDIA_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' },
),
[MEDIA_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([MEDIA_MOTION, MEDIA_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() inside expose() to access shared state from ancestor providers. The returned Memo<T> is reactive — when the provider's signal updates, all consumers update automatically.
Consumer Component#
Here's a simple card that displays the current motion and theme preferences:
export default defineComponent(
'card-mediaqueries',
({ expose, first, requestContext, watch }) => {
const motionEl = first('.motion')
const themeEl = first('.theme')
const viewportEl = first('.viewport')
const orientationEl = first('.orientation')
expose({
motion: requestContext(MEDIA_MOTION, 'unknown'),
theme: requestContext(MEDIA_THEME, 'unknown'),
viewport: requestContext(MEDIA_VIEWPORT, 'unknown'),
orientation: requestContext(MEDIA_ORIENTATION, 'unknown'),
})
return [
motionEl && watch('motion', bindText(motionEl)),
themeEl && watch('theme', bindText(themeEl)),
viewportEl && watch('viewport', bindText(viewportEl)),
orientationEl && watch('orientation', bindText(orientationEl)),
]
},
)
Full Example#
- Motion Preference:
- Theme Preference:
- Device Viewport:
- Device Orientation:
ContextMedia source code
Loading...
CardMediaqueries source code
Loading...