UIElement Docs Version 0.13.0

🔗 🔄 Data Flow

UIElement enables smooth data flow between components using signals, events, and context. State can be passed down to child components, events can bubble up to notify parents of changes, and context can propagate across the component tree to share global state efficiently. This page explores different patterns for structuring data flow, helping you create modular, loosely coupled components that work seamlessly together.

🔗 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:

  • ProductCatalog (Parent):
    • Tracks all SpinButton components in its subtree and calculates the total count of items in the shopping cart.
    • Passes that total to a InputButton.
  • InputButton (Child):
    • Displays a badge in the top-right corner when the badge property is set.
    • Does not track any state – it simply renders whatever value is passed to it.
  • SpinButton (Child):
    • Displays an Add to Cart button initially.
    • When an item is added, it transforms into a stepper (increment/decrement buttons).

Although InputButton and SpinButton are completely independent, they need to work together.
So ProductCatalog coordinates the data flow between them.

🔗 Parent Component: ProductCatalog

The parent component (ProductCatalog) knows about its children, meaning it can retrieve state from and pass state to them.

First, we need to observe the quantities of all SpinButton components. For this, we create a signal of all children matching the spin-button selector:

js

component("product-catalog", {
	total: (el) => () =>
		selection(el, "spin-button")
			.get()
			.reduce((sum, item) => sum + item.value, 0),
}, () => []);

The selection() function returns a signal that emits an array of all matching elements. In contrast to a static querySelectorAll() call, the selection() function is reactive and updates whenever new elements are added or removed from the DOM.

Then, we need to calculate the total of all product quantities and pass it on to the InputButton component. In UIElement we use the pass() function to share state across components:

js

component("product-catalog", {
	total: (el) => () =>
		selection(el, "spin-button")
			.get()
			.reduce((sum, item) => sum + item.value, 0),
}, (el, { first }) => [
	first("input-button",
		pass({
			badge: () => (el.total > 0 ? String(el.total) : ""),
			disabled: () => !el.total,
		}),
	),
]);

Allright, that's it!

  • ✅ Whenever one of the value signals of a <spin-button> updates, the total in the badge of <input-button> automatically updates.
  • ✅ No need for event listeners or manual updates!

🔗 Child Component: InputButton

The InputButton component displays a badge when needed – it does not know about any other component nor track state itself. It just exposes a reactive property badge of type string and has an effect to react to state changes that updates the DOM subtree.

js

component("input-button", {
	badge: asString(RESET),
}, (_, { first }) => [
	first(".badge", setText("badge")),
])
  • ✅ Whenever the badge property is updated by a parent component, the badge text updates.
  • ✅ If badge is an empty string, the badge indicator is hidden (via CSS).

🔗 ChildComponent: SpinButton

The SpinButton 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.

js

component("spin-button", {
	value: asInteger(),
}, (el, { all, first }) => {
	const max = asInteger(9)(el, el.getAttribute("max"));
	const isZero = () => el.value === 0;
	return [
		first(".value",
			setText("value"),
			setProperty("hidden", isZero),
		),
		first(".decrement",
			setProperty("hidden", isZero),
			on("click", () => {
				el.value--;
			}),
		),
		first(".increment",
			setProperty("disabled", () => el.value >= max),
			on("click", () => {
				el.value++;
			}),
		),
		all("button",
			on("keydown", (e) => {
				const { key } = e;
				if (["ArrowUp", "ArrowDown", "-", "+"].includes(key)) {
					e.stopPropagation();
					e.preventDefault();
					if (key === "ArrowDown" || key === "-") el.value--;
					if (key === "ArrowUp" || key === "+") el.value++;
				}
			}),
		),
	];
});
  • ✅ 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 .value element.

🔗 Full Example

Here's how everything comes together:

  • Each SpinButton tracks its own value.
  • The ProductCatalog sums all quantities and passes the total to InputButton.
  • The InputButton displays the total if it's greater than zero.

No custom events are needed – state flows naturally!

Shop

  • Product 1

  • Product 2

  • Product 3

ProductCatalog Source Code

Loading...

InputButton Source Code

Loading...

SpinButton Source Code

Loading...

🔗 Custom Events

Passing state down works well when a parent component can directly observe child state, but sometimes a child needs to notify its parent about an action without managing shared state itself.

Let's consider a Todo App, where users can add tasks:

  • TodoApp (Parent):
    • Holds the list of todos as a state signal.
    • Listens for an add-todo event from the child (TodoForm).
    • Updates the state when a new todo is submitted.
  • TodoForm (Child):
    • Handles user input but does not store todos.
    • Emits an add-todo event when the user submits the form.
    • Lets the parent decide what to do with the data.

🔗 Why use events here?

  • The child doesn't need to know where the data goes – it just emits an event.
  • The parent decides what to do with the new todo (e.g., adding it to a list).
  • This keeps TodoForm reusable – it could work in different apps without modification.

🔗 Parent Component: TodoApp

The parent (TodoApp) listens for events and calls the .addItem() method on TodoList when a new todo is added:

js

this.self.on('add-todo', e => {
	this.querySelector('todo-list').addItem(e.detail)
})
  • Whenever TodoForm emits an 'add-todo' event, a new task is appended to the todo list.
  • ✅ The event carries data (e.detail), so the parent knows what was submitted.

🔗 Child Component: TodoForm

The child (TodoForm) collects user input and emits an event when the form is submitted:

js

const input = this.querySelector('input-field')
this.first('form').on('submit', e => {
	e.preventDefault()

	// Wait for microtask to ensure the input field value is updated before dispatching the event
	queueMicrotask(() => {
		const value = input?.get('value')?.trim()
		if (value) {
			this.self.emit('add-todo', value)
			input?.clear()
		}
	})
})
  • ✅ The form does NOT store the todo – it just emits an event.
  • ✅ The parent (TodoApp) decides what happens next.
  • ✅ The event includes data (value), making it easy to handle.

🔗 Full Example

Here's how everything comes together:

  • User types a task into input field in TodoForm.
  • On submit, TodoForm emits 'add-todo' with the new task as event detail.
  • TodoApp listens for 'add-todo' and updates the todo list.

    Well done, all done!

    task tasks remaining

    Filter
    TodoApp Source Code

    Loading...

    InputField Source Code

    Loading...

    InputButton Source Code

    Loading...

    InputCheckbox Source Code

    Loading...

    InputRadiogroup Source Code

    Loading...

    🔗 Providing Context

    🔗 Consuming Context

    🔗 Next Steps