Compago is a minimalist framework inspired by Domain-Driven Design for building applications using Web Components.
Although most components are isomorphic and can be used independently, Compago works best with Lit for which it offers extensions focused on simplifying advanced state management in web components.
Node.js:
npm i compago
import { ... } from "compago";
Components can also be imported separately, to avoid loading extra dependencies, for example:
import { Result } from "compago/result";
import { Observable } from "compago/observable";
Deno:
import { ... } from "https://raw.githubusercontent.com/zandaqo/compago/master/mod.ts"
UI frameworks like React and Lit provide built-in mechanisms to deal with simple
UI state expressed as primitive values such as useState
hooks in React or
Lit's reactive properties. However, those mechanisms are cumbersome to work with
when dealing with a complex domain state that involves nested objects and
arrays. To give reactivity to complex domain objects, Compago introduces the
Observable
class. Observable wraps a given domain object into a proxy that
reacts to changes on the object and all its nested structures with a change
event. When used in Lit elements, additional helpers like @observer
,
@observe
decorators and bond
directive make reactivity and data binding
seamless.
import { html, LitElement } from "lit";
import { bond, Observable, observe, observer } from "compago";
class Todo {
description = "";
done = false;
}
@observer()
class TodoItem extends LitElement {
// Create an observable of a Todo object tied to the element.
// The observable will be treated as internal state and updates within the observable
// (and all nested objects) will be propagated
// through the usual lifecycle of reactive properties.
@observe()
state = new Observable(new Todo());
// You can hook into updates to the observable properties just like reactive ones.
// Names for the nested properties are given as a path, e.g. `state.done`, `state.description`
protected updated(changedProperties: Map<string, unknown>) {
// check if the description of the todo has changed
if (changedProperties.has("state.description")) {
console.log("Todo description has changed!");
}
}
render() {
return html`
<div>
<input .value=${this.state.description}
<-- Bind the input value to the 'description' property of our observable -->
@input=${bond({ to: this.state, key: "description" })} />
<input type="checkbox" ?checked=${this.state.done}
<-- Bind the 'checked' attribute of the input to the 'done' property of our observable -->
@click=${
bond({ to: this.state, key: "done", attirubute: "checked" })
} />
</div>
`;
}
}
Observable
makes monitoring changes on JavaScript objects as seamless as
possible using the built-in Proxy and EventTarget interfaces. Observable wraps a
given object into a proxy that emits change
events whenever a change happens
to the object or its "nested" objects and arrays. Since Observable
is an
extension of DOM's EventTarget, listening to changes is done through the
standard event handling mechanisms.
import { Observable } from "compago";
class Todo {
description = "...";
done = false;
}
const todo = new Observable(new Todo());
todo.addEventListener("change", (event) => {
console.log(event);
});
todo.done;
//=> false
todo.done = true;
//=> ChangeEvent {
//=> type: 'change',
//=> kind: 'SET'
//=> path: '.done'
//=> previous: false,
//=> defaultPrevented: false,
//=> cancelable: false,
//=> timeStamp: ...
//=>}
//=> true
todo.done;
//=> true
JSON.stringify(todo);
//=> { "description": "...", "done": true }
Observable only monitors own, public, enumerable, non-symbolic properties, thus,
any other sort of properties (i.e. private, symbolic, or non-enumerable) can be
used to manipulate data without triggering change
events, e.g. to store
computed properties.
Observables complement Lit element's reactive properties allowing separation of
complex domain state (held in observables) and UI state (held in properties). To
simplify working with observables, Compago offers ObserverElement
mixin or
observer
and observe
decorators that handle attaching and detaching from
observables and triggering LitElement updates when observables detect changes.
import { observer, observe } from 'compago';
class Comment {
id = 0;
text = '';
meta = {
author: '';
date: new Date();
}
}
@observer()
@customElement('comment-element');
class CommentElement extends LitElement {
// Set up `comment` property to hold a reference to an observable
// holding a domain state. Any change to the comment will trigger
// the elements update cycle.
@observe() comment = new Observable(new Comment());
@property({ type: Boolean }) isEditing = false;
...
updated(changedProperties: PropertyValues<any>): void {
if (changedProperties.has('comment.text')) {
// react if the comment's text property has changed
}
}
}
While decorators are quite popular in the Lit world, you can achieve the same
effect without them by using ObserverMixin
and specifying list of observables
in a static property:
const CommentElement = ObserverMixin(class extends LitElement {
static observables = ['comment'];
...
comment = new Observable(new Comment());
...
})
We often have to take values from DOM elements (e.g. input fields) and update
our UI or domain states with them. To simplify the process, Compago offers the
bond
directive. The directive provides a declarative way to define an event
listener that (upon being triggered) takes the specified value from its DOM
element, optionally validates and parses it, and sets it on desired object, be
it the element itself or an observable domain state object.
class CommentElement extends LitElement {
// Set up `comment` property to hold a reference to an observable
// holding a domain state. Any change to the comment will trigger
// the elements update cycle.
@observe()
comment = new Observable(new Comment());
...
render() {
return html`
<div>
<input .value=${this.comment.text}
<-- Upon input event take the input's value, validate it
and set as the text of our comment -->
@input=${
bond({
to: this.comment,
key: "text",
validate: (text) => text.length < 3000,
})
} />
</div>
`;
}
}
Observable are fully independent and can be shared between components. For example, a parent can share "parts" of an observable with children as shown in our example [todo app]:
@customElement("todo-app")
@observer()
export class TodoApp extends LitElement {
// Here we create an observable that holds an array of todo objects.
@observe()
state = new Observable({ items: [] as Array<Todo> });
...
render() {
return html`
<h1>Todos Compago & Lit</h1>
<section>
${
repeat(this.state.items, (todo) =>
todo.id, (todo) =>
// We supply each todo object to a child element.
// Now when any of the todo objects is changed, it will trigger
// re-render of the todo-item and the parent todo-app,
// but not the sibling todo-items.
html`<todo-item .todo=${todo}></todo-item>`)
}
</section>
`;
}
}
@observer()
@customElement("todo-item")
export class TodoItem extends LitElement {
@observe()
todo!: Todo;
render() {
return html`
<input type="checkbox" .checked=${this.todo.done} @click=${
bond({
to: this.todo,
key: "done",
property: "checked",
})
}>
<label>${this.todo.text}</label>
<button @click=${this.onRemove}>x</button>
`;
}
}
Observables can be shared not only between parents and children, but also between siblings, or the same observable can be shared by different components, in fact, you can use a single observable to hold all state in your app (as a "global store").
A repository is a design pattern of data-centric, domain-driven design that
encapsulates the logic for accessing data sources while abstracting the
underlying persistence technology. Web applications exchange data with a variety
of data sources, such as servers exposed through a RESTful API, local storage
using IndexedDB API, or databases through their respective drivers. Compago
offers a unifying interface for repositories and implementations for RESTful
APIs (RESTRepository
) and local storage with IndexedDB (LocalRepository
).
import { RESTRepository } from 'compago';
class Comment {
id = 0;
text = '';
meta = {
author: '';
date: new Date();
}
}
// Create a repository for comments that will access the API end point `/comments`
const remoteRepository = new RESTRepository(Comment, '/comments', 'id');
// retrieve the comment with id 1
// by sending a GET request to `/comments/1` API end-point
const readResult = await remoteRepository.read(1);
readResult.ok
//=> true if successful
const comment = readResult.value
//=> Comment {...}
MIT @ Maga D. Zandaqo