Skip to content

A minimalist framework inspired by Domain-Driven Design for building applications using Web Components

License

Notifications You must be signed in to change notification settings

zandaqo/compago

Repository files navigation

Compago

Actions Status npm

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.

Installation

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"

State Management with Observables

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.

Quick Example

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>
    `;
  }
}

Observables

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.

Using with Lit

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()); 
  ...
})

Data Binding

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>
    `;
  }
}

Shared State

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").

State Persistance with Repositories

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 {...}

License

MIT @ Maga D. Zandaqo

About

A minimalist framework inspired by Domain-Driven Design for building applications using Web Components

Topics

Resources

License

Stars

Watchers

Forks