Cami.js is a simple yet powerful toolkit for interactive islands in web applications. No build step required.
More in the announcements: link
Visit the site for API Reference and Examples: camijs.com
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:
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.
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>
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:
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.
store
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.Here are some examples of how to use the new features:
<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>
<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>
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.
value
getter/setterHere'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 DisposalApart 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 componentsOne 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
}
]
});
}
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.
This is the version that we use in unpkg.
Couple of variations:
v0.0.8:
In ReactiveElement
, state fields are now private. You can only use states via .getState()
and .setState()
.