Cami.js Versions Save

Cami.js is a simple yet powerful toolkit for interactive islands in web applications. No build step required.

v0.3.3

5 months ago

More in the announcements: link

Visit the site for API Reference and Examples: camijs.com

v0.3.0

5 months ago

Release Notes for Cami.js v0.3.0

We are excited to announce the release of Cami.js v0.3.0! This version introduces significant improvements to the developer experience and the reactivity system.

Here's what's new:

Observable Inference

The boilerplate for defining observables has been reduced. You can now directly initialize properties in the class definition, and they will be automatically treated as observables. This makes component state management more straightforward.

Here's how simple a counter now looks like with this change:

<article>
  <h1>Counter</h1>
  <counter-component
  ></counter-component>
</article>
<script src="https://unpkg.com/cami@latest/build/cami.cdn.js"></script>
<script type="module">
  const { html, ReactiveElement } = cami;

  class CounterElement extends ReactiveElement {
    count = 0

    template() {
      return html`
        <button @click=${() => this.count--}>-</button>
        <button @click=${() => this.count++}>+</button>
        <div>Count: ${this.count}</div>
      `;
    }
  }

  customElements.define('counter-component', CounterElement);
</script>

Notice that you don't have to define observables or effects. You can just directly mutate the state, and the component will automatically update its view.

Streamlined State Management

The query and mutation methods have been added to provide a more seamless experience when fetching and updating data. With these methods, you can easily manage asynchronous data with built-in support for caching, stale time, and refetch intervals. This is inspired by React Query.

Here's how a blog component would look like, with optimistic UI.

<article>
  <h1>Blog</h1>
  <blog-component></blog-component>
</article>
<script src="https://unpkg.com/cami@latest/build/cami.cdn.js"></script>
<script type="module">
  const { html, ReactiveElement, http } = cami;

  class BlogComponent extends ReactiveElement {
    posts = this.query({
      queryKey: ["posts"],
      queryFn: () => {
        return fetch("https://jsonplaceholder.typicode.com/posts").then(res => res.json())
      },
      staleTime: 1000 * 60 * 5 // 5 minutes
    })

    //
    // This uses optimistic UI. To disable optimistic UI, remove the onMutate and onError handlers.
    //
    addPost = this.mutation({
      mutationFn: (newPost) => {
        return fetch("https://jsonplaceholder.typicode.com/posts", {
          method: "POST",
          body: JSON.stringify(newPost),
          headers: {
            "Content-type": "application/json; charset=UTF-8"
          }
        }).then(res => res.json())
      },
      onMutate: (newPost) => {
        // Snapshot the previous state
        const previousPosts = this.posts.data;

        // Optimistically update to the new value
        this.posts.update(state => {
          state.data.push({ ...newPost, id: Date.now() });
        });

        // Return the rollback function and the new post
        return {
          rollback: () => {
            this.posts.update(state => {
              state.data = previousPosts;
            });
          },
          optimisticPost: newPost
        };
      },
      onError: (error, newPost, context) => {
        // Rollback to the previous state
        if (context.rollback) {
          context.rollback();
        }
      },
      onSettled: () => {
        // Invalidate the posts query to refetch the true state
        if (!this.addPost.isSettled) {
          this.invalidateQueries(['posts']);
        }
      }
    });

    template() {
      if (this.posts.data) {
        return html`
          <button @click=${() => this.addPost.mutate({
            title: "New Post",
            body: "This is a new post.",
            userId: 1
          })}>Add Post</button>
          <ul>
            ${this.posts.data.slice().reverse().map(post => html`
              <li>
                <h2>${post.title}</h2>
                <p>${post.body}</p>
              </li>
            `)}
          </ul>
        `;
      }

      if (this.posts.status === "loading") {
        return html`<div>Loading...</div>`;
      }

      if (this.posts.status === "error") {
        return html`<div>Error: ${this.posts.error.message}</div>`;
      }

      if (this.addPost.status === "pending") {
        return html`
        ${this.addPost.status === "pending" ? html`
          <li style="opacity: 0.5;">
            Adding new post...
          </li>
        ` : ''}
        <div>Adding post...</div>`;
      }

      if (this.addPost.status === "error") {
        return html`<div>Error: ${this.addPost.error.message}</div>`;
      }
    }
  }

  customElements.define('blog-component', BlogComponent);
</script>

v0.2.0

5 months ago

Release Notes for v0.2.0 - setup and store simplification

In this release, we've made significant changes to the ReactiveElement class and the store function to simplify their usage and make them more intuitive. Here are the key changes:

Changes to ReactiveElement

  • The define method has been renamed to setup to better reflect its purpose. This method is used to set up observables, computed properties, and attributes for the element.

  • The subscribe method has been renamed to connect to better reflect its purpose. This method is used to connect an observable from a store to the element.

Changes to store

  • The store function now supports actions as part of the initial state. If a function is provided in the initial state, it will be registered as an action.

Examples

Here are some examples of how to use the new features:

Counter Component

<script type="module">
  const { html, ReactiveElement } = cami;

  class CounterElement extends ReactiveElement {
    count = 0

    constructor() {
      super();
      this.setup({
        observables: ['count']
      })
    }

    template() {
      return html`
        <button @click=${() => this.count--}>-</button>
        <button @click=${() => this.count++}>+</button>
        <div>Count: ${this.count}</div>
      `;
    }
  }

  customElements.define('counter-component', CounterElement);
</script>

Todo List Component (uses store)

<script type="module">
  const { html, ReactiveElement } = cami;

  const TodoStore = cami.store({
    todos: [],
    add: (store, todo) => {
      store.todos.push(todo);
    },
    delete: (store, todo) => {
      const index = store.todos.indexOf(todo);
      if (index > -1) {
        store.todos.splice(index, 1);
      }
    }
  });

  TodoStore.use((context) => {
    console.log(`Action ${context.action} was dispatched with payload:`, context.payload);
  });

  class TodoListElement extends ReactiveElement {
    todos = this.connect(TodoStore, 'todos');

    constructor() {
      super();
      this.setup({
        observables: ['todos']
      })
    }

    template() {
      return html`
        <input id="newTodo" type="text" />
        <button @click=${() => {
          const newTodo = document.getElementById('newTodo').value;
          TodoStore.add(newTodo);
          document.getElementById('newTodo').value = '';
        }}>Add</button>
        <ul>
          ${this.todos.map(todo => html`
            <li>
              ${todo}
              <button @click=${() => TodoStore.delete(todo)}>Delete</button>
            </li>
          `)}
        </ul>
      `;
    }
  }

  customElements.define('todo-list-component', TodoListElement);
</script>

v0.1.2

5 months ago

This release improves developer experience in a significant way, compared to the prior versions.

For one, you can get and set to values as if they're normal primitives or objects, and any setter will automatically re-render the template.

Simpler, More Intuitive API - No more value getter/setter

Here's how the counter component looked before:

Before:

<script type="module">
  const { html, ReactiveElement } = cami;
  class CounterElement extends ReactiveElement {
    count = this.observable(0);

    constructor() {
      super();
    }

    template() {
      return html`
        <button @click=${() => this.count.value--}>-</button>
        <button @click=${() => this.count.value++}>+</button>
        <div>Count: ${this.count.value}</div>
      `;
    }
  }

  customElements.define('counter-component', CounterElement);

Here's how it looks like now:

<counter-element></counter-element>
<script src="https://unpkg.com/cami@latest/build/cami.cdn.js"></script>
<script type="module">
  const { html, ReactiveElement } = cami;
  class CounterElement extends ReactiveElement {
    count = 0

    constructor() {
      super();
      this.define({
        observables: ['count'],
      })
    }

    template() {
      return html`
        <button @click=${() => this.count--}>-</button>
        <button @click=${() => this.count++}>+</button>
        <div>Count: ${this.count}</div>
      `;
    }
  }

  customElements.define('counter-component', CounterElement);
</script>

The changes result in a more natural API. What is exchanged is that it's now required to define observables through the this.define(...) method:

  this.define({
    observables: ['count'],
  })

Computeds, Dependency Tracking, Automatic Disposal

Apart from observables, you can also define computeds. Computeds will turn getter methods assigned in the define method as a computed function. This is computed lazily & uses dependency tracking, ie. it only computes its input values once the computed method is called. All computeds used are disposed automatically under the hood.

Here's a brief example:

//...
  class CounterElement extends ReactiveElement {
    count = 0;

    constructor() {
      super();

      this.define({
        observables: ['count'],
        computed: ['countSquared', 'countCubed', 'countQuadrupled', 'countPlusRandom', 'countSqrt'],
      })
    }

    get countSquared() {
      return this.count * this.count;
    }

    get countCubed() {
      return this.countSquared * this.count;
    }
//...

attributes in web components

One can also pass values through web component attributes, and it will be interpreted as an observable. These are named as attributes in the define method. You'll need to pass the target variable name and the parsing function to use.

<my-component
    todos='{"data": ["Buy milk", "Buy eggs", "Buy bread"]}'
  ></my-component>
</article>
<script src="./build/cami.cdn.js"></script>
<!-- CDN version below -->
<!-- <script src="https://unpkg.com/cami@latest/build/cami.cdn.js"></script> -->
<script type="module">
 const { html, ReactiveElement } = cami;

  class MyComponent extends ReactiveElement {
    todos = []

    constructor() {
      super();
      this.define({
        attributes: [{
            name: 'todos',
            parseFn: (v) => JSON.parse(v).data
          }
        ]
      });
    }

Cami Query

Lastly, there is query which allows a more ergonomic way to fetch data. It automatically sets isLoading, error, and data, which makes it easier for you to style your views.


  class BlogComponent extends ReactiveElement {
    posts = {}

    constructor() {
      super();
      this.define({
        observables: ['posts'],
      });
    }

    connectedCallback() {
      super.connectedCallback();
      this.posts = this.query({
        queryKey: ["posts"],
        queryFn: () => fetch("https://jsonplaceholder.typicode.com/posts").then(posts => posts.json())
      });
    }

    template() {
      if (this.posts.isLoading) {
        return html`<div>Loading...</div>`;
      }

      if (this.posts.error) {
        return html`<div>Error: ${this.posts.error.message}</div>`;
      }

      if (this.posts.data) {
        return html`
          <ul>
            ${this.posts.data.map(post => html`
              <li>
                <h2>${post.title}</h2>
                <p>${post.body}</p>
              </li>
            `)}
          </ul>
        `;
      }
    }

There are a few more goodies, and a milestone for 0.2.0 or so is better documentation & more examples.

For now, kindly check out the examples folder if you want to see working examples.

Credits:

  • Discussion about syntax here.
  • WICG Observable Proposal (we now follow this under the hood, with some modifications)
  • Cami Query is inspired by React Query. You can almost say that it's a light port of TanStack Query into Cami Web Components. Note that we don't have mutation functions yet.

v0.0.10

6 months ago

This is the version that we use in unpkg.

Couple of variations:

v0.0.9

6 months ago
  • API simplification: From an observable setter & a setFields method to updated nested fields, it's now collapsed into one update method for updating the observable state
  • As such, Observables don't use getters and setters anymore (which may be hard to track as applications scale). Instead, we now use .value to get the state and .update(updateFn(val)) to set state
  • Subscriptions are now more intuitive via the `this.prop = this.subscribe(store, prop) syntax.

v0.0.8

6 months ago

v0.0.8:

  • deep observable & store updates
  • computed and effect functions
  • basic nested object support
  • added observables

v0.0.4

6 months ago

In ReactiveElement, state fields are now private. You can only use states via .getState() and .setState().

v0.0.3

6 months ago