UIElement Docs Version 0.9.4

๐Ÿš€ Advanced Topics

Explore the advanced features of UIElement, including context for managing shared client-side state, scheduling mechanisms to control timing in custom effects, and shared functionality through composition.

Managing Global State with Context

Context allows sharing client-side state across components, particularly for states that depend on browser APIs like media queries. Use context mainly for client-only state, and pass server-rendered state as attributes.

Context Providers

A context provider component manages shared state and exposes it to all sub-components. Use the naming pattern ${something}-provider and provide contexts prefixed with ${something}- for clear namespacing.

class MediaProvider extends UIElement {
			static providedContexts = ['media-theme'];
		  
			connectedCallback() {
			  // Monitor changes to the (prefers-color-scheme) media query
			  const mql = matchMedia('(prefers-color-scheme: dark)');
			  this.set('media-theme', mql.matches);
			  super.connectedCallback();
			  mql.onchange = (e) => this.set('media-theme', e.matches);
			}
		  }
		  MediaProvider.define('media-provider');

Context Consumers

Components that consume context need to declare which context keys they use through static consumedContexts. This allows them to inherit state from any context provider up the DOM tree.

class ThemedComponent extends UIElement {
			static consumedContexts = ['media-theme'];
		  
			connectedCallback() {
			  super.connectedCallback();
			  this.first('.box').sync(setStyle('background-color', () =>
				this.get('media-theme') ? 'black' : 'white'
			  ));
			}
		  }
		  ThemedComponent.define('themed-component');

Best Practices for Context Usage

Scheduling & Controlling Effects

The scheduler in UIElement manages effect timing across three phases: preparation, DOM updates, and cleanup. This ensures efficient batching and deduplication of DOM changes.

Preparation Phase

This is where signals are accessed and processed synchronously. You can also enqueue DOM updates and cleanup functions manually.

this.effect(() => {
			const newItem = this.get('add');
			if (newItem) {
			  const list = this.querySelector('ul');
			  const template = this.querySelector('template').content.cloneNode(true);
			  template.querySelector('li').textContent = newItem; // fill content from signal
			  enqueue(() => list.appendChild(template), [list, 'insert']);
			  this.set('add', ''); // clear the 'add' signal
			}
			return () => console.log(`New item '${newItem}' added`);
		  });

In this example, a new item is created from a template and appended to a list, and the add signal is cleared. A cleanup function logs the new item after updates are complete.

DOM Update Phase

All DOM updates are efficiently batched and flushed on the next animation frame. Multiple updates to the same element within the same tick are merged, reducing unnecessary reflows.

// Multiple updates on the same element
		  this.first('.status').sync(setText('statusText'));
		  this.first('.status').sync(setStyle('color', 'textColor')); // Deduplicated

Cleanup Phase

After all DOM updates are completed, any enqueued cleanup operations are executed. Cleanup functions are returned directly from the effect.

this.effect(() => {
			// Preparation logic here
			return () => {
			  // Cleanup logic
			};
		  });

Keep cleanup operations minimal to maintain optimal performance and only use them when necessary.

Best Practices for Custom Effects

Sharing Functionality Across Components

Instead of using inheritance or mixins, which can lead to tight coupling, you can share common functionality across multiple components using composition. A controller class is an excellent way to achieve this.

Using a Controller for Composition

A controller encapsulates reusable logic and can be attached to any component as a field. One example is a VisibilityObserver, which tracks whether a component is visible in the viewport.

// controllers/VisibilityObserver.js
		  export class VisibilityObserver {
			constructor() {
			  this.observer = new IntersectionObserver((entries) => {
				entries.forEach(entry => {
				  entry.target.set('visible', entry.isIntersecting);
				});
			  });
			}
		  
			connect(component) {
			  this.observer.observe(component);
			}
		  
			disconnect() {
			  this.observer.disconnect();
			}
		  }

Integrating the Controller into a Component

To use the VisibilityObserver, import it and attach it as a field on your component. Then connect it during connectedCallback() and disconnect it in disconnectedCallback().

import { VisibilityObserver } from './controllers/VisibilityObserver.js';
		  
		  class VisibilityComponent extends UIElement {
			constructor() {
			  super();
			  this.visibilityObserver = new VisibilityObserver();
			}
		  
			connectedCallback() {
			  super.connectedCallback();
			  this.visibilityObserver.connect(this); // Connect the observer
			  this.first('.box').sync(setText(() => this.get('visible') ? 'Visible' : 'Hidden'));
			}
		  
			disconnectedCallback() {
			  super.disconnectedCallback();
			  this.visibilityObserver.disconnect(); // Clean up the observer
			}
		  }
		  VisibilityComponent.define('visibility-component');

HTML Usage Example

<visibility-component>
			<div class="box">Visibility state will be shown here</div>
		  </visibility-component>

Best Practices for Using Controllers

Conclusion & Next Steps

By using context, scheduling effects efficiently, and sharing functionality through composition, you can build robust and maintainable UIElement components. Explore "Examples & Recipes" to see these concepts in action or visit "Troubleshooting & FAQs" for solutions to common issues.