๐ 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
- Use namespaced keys: Context keys should be two words separated by a dash (e.g.,
media-theme). - Client-side state only: Use context for browser-dependent state. Server-rendered state should be passed as attributes.
- Call super lifecycle methods: Always call
super.connectedCallback()andsuper.disconnectedCallback()if overriding them in context providers or consumers.
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
- Avoid double encapsulation: Do not use auto-effects within custom effects. Handle DOM manipulations manually when needed.
- Separate logic phases: Clearly separate signal preparation, DOM updates, and cleanup for better performance.
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
- Encapsulation: Keep the controller logic separate from the component logic to ensure modularity and reusability.
- Attach Controllers as Fields: Attach controllers to components as fields (e.g.,
this.visibilityObserver) for easy access and management. - Manage Lifecycle Carefully: Ensure that the controller's
connect()anddisconnect()methods are properly tied to the component's lifecycle callbacks.
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.