Current version: 0.0.13.
Bundle Size: 8kb minified & gzipped.
A minimalist & flexible toolkit for interactive islands & state management in hypermedia-driven web applications.
Motivation
I wanted a minimalist javascript library that has no build steps, great debuggability, and didn’t take over my front-end.
My workflow is simple: I want to start any application with normal HTML/CSS, and if there were fragments or islands that needed to be interactive (such as dashboards & calculators), I needed a powerful enough library that I could easily drop in without rewriting my whole front-end. Unfortunately, the latter is the case for the majority of javascript libraries out there.
That said, I like the idea of declarative templates, uni-directional data flow, time-travel debugging, and fine-grained reactivity. But I wanted no build steps (or at least make ‘no build’ the default). So I created Cami.
Key Features:
- Reactive Web Components: We suggest to start any web application with normal HTML/CSS, then add interactive islands with Cami’s reactive web components. Uses fine-grained reactivity with observables, computed properties, and effects. Also supports for deeply nested updates. Uses the Light DOM instead of Shadow DOM.
- Tagged Templates: Declarative templates with lit-html. Supports event handling, attribute binding, composability, caching, and expressions.
- Store / State Management: When you have multiple islands, you can use a singleton store to share state between them, and it acts as a single source of truth for your application state. Redux DevTools compatible.
- Easy Immutable Updates: Uses Immer under the hood, so you can update your state immutably without excessive boilerplate.
- Anti-Features: You can’t be everything to everybody. So we made some hard choices: No Build Steps, No Client-Side Router, No JSX, No Shadow DOM. We want you to build an MPA, with mainly HTML/CSS, and return HTML responses instead of JSON. Then add interactivity as needed.
Who is this for?
- Lean Teams or Solo Devs: If you’re building a small to medium-sized application, I built Cami with that in mind. You can start with
ReactiveElement
, and once you need to share state between components, you can add our store. It’s a great choice for rich data tables, dashboards, calculators, and other interactive islands. If you’re working with large applications with large teams, you may want to consider other frameworks. - Developers of Multi-Page Applications: For folks who have an existing server-rendered application, you can use Cami to add interactivactivity to your application, along with other MPA-oriented libraries like HTMX, Unpoly, Turbo, or TwinSpark.
Philosophy
- Less Code is Better: In any organization, large or small, team shifts are inevitable. It’s important that the codebase is easy to understand and maintain. This allows any enterprising developer to jump in, and expand the codebase that fits their specific problems.
Get Started & View Examples
To see some examples, just do the following:
git clone git@github.com:kennyfrc/cami.js.git
cd cami.js
bun install --global serve
bunx serve
Open http://localhost:3000 in your browser, then navigate to the examples folder. In the examples folder, you will find a series of examples that illustrate the key concepts of Cami.js. These examples are numbered & ordered by complexity.
Key Concepts / API
ReactiveElement Class
, Observable
Objects, and HTML Tagged Templates
ReactiveElement
is a class that extends HTMLElement
to create reactive web components. These components can automatically update their view (the template
) when their state changes.
Automatic updates are done by observables. An observable is an object that can be observed for state changes, and when it changes, it triggers an effect (a function that runs in response to changes in observables).
Cami’s observables have the following characteristics:
- It has a
value
property that holds the current value of the observable. - It has an
update
method that allows you to update the value of the observable.
When you update the value of an observable, it will automatically trigger a re-render of the component’s html tagged template
. This is what makes the component reactive.
Let’s illustrate these three concepts with an example. Here’s a simple counter component:
class CounterElement extends ReactiveElement { constructor() { super(); this.count = this.observable(0); // Defines an observable property 'count' with an initial value of 0 }
increment() { this.count.update(value => value + 1); // Updates the value of 'count' observable }
template() { return html`
Count: ${this.count.value}
// Accesses the current value of 'count' observable `; } }
customElements.define('counter-element', CounterElement); ” dir=”auto”>
<counter-element>counter-element> <script type="module"> import { html, ReactiveElement } from 'https://unpkg.com/cami@latest/build/cami.module.js'; class CounterElement extends ReactiveElement { constructor() { super(); this.count = this.observable(0); // Defines an observable property 'count' with an initial value of 0 } increment() { this.count.update(value => value + 1); // Updates the value of 'count' observable } template() { return html` <button @click=${() => this.increment()}>Incrementbutton> <p>Count: ${this.count.value}p> // Accesses the current value of 'count' observable `; } } customElements.define('counter-element', CounterElement); script>
In this example, CounterElement
is a reactive web component that maintains an internal state (count
) and updates its view whenever the state changes.
Basics of Observables & Templates
Creating an Observable:
In the context of a ReactiveElement
, you can create an observable using the this.observable()
method. For example, to create an observable count
with an initial value of 0
, you would do:
this.count = this.observable(0);
Getting the Value:
You can get the current value of an observable by accessing its value
property. For example, you can get the value of count
like this:
Setting the Value:
To update the value of an observable, you use the update
method. This method accepts a function that receives the current value and returns the new value. For example, to increment count
, you would do:
An interesting thing to note (which many libaries don’t support with one method/function) is that Cami’s observables support deeply nested updates. This means that if your observable’s value is an object, you can update deeply nested properties within that object. Example:
first
property of the name
object is updated. The rest of the user
object remains the same.
Template Rendering:
Cami.js uses lit-html for its template rendering. This allows you to write HTML templates in JavaScript using tagged template literals. Think of it as fancy string interpolation.
Here’s the example from above:
html
is a function that gets called with the template literal. It processes the template literal and creates a template instance that can be efficiently updated and rendered.
The ${}
syntax inside the template literal is used to embed JavaScript expressions. These expressions can be variables, properties, or even functions. In the example above, ${this.count.value}
will be replaced with the current value of the count
observable, and ${() => this.count.update(value => value + 1)}
is a function that increments the count when the button is clicked.
The @click
syntax is used to attach event listeners to elements. In this case, a click event listener is attached to the button element.
Basics of Computed Properties & Effects
Computed Properties:
Computed properties are a powerful feature in Cami.js that allow you to create properties that are derived from other observables. These properties automatically update whenever their dependencies change. This is particularly useful for calculations that depend on one or more parts of the state.
For instance, in the CounterElement
example from _001_counter.html
, a computed property countSquared
is defined as the square of the count
observable:
countSquared
will always hold the square of the current count value, and will automatically update whenever count
changes. This is ideal for calculations like this, but can also be used for other derived values such as total price in a shopping cart (based on quantities and individual prices), or a boolean flag indicating if a form is valid (based on individual field validations).
Effects:
Effects in Cami.js are functions that run in response to changes in observable properties. They are a great way to handle side effects in your application, such as integrating with non-reactive components, emitting custom events, or logging/debugging.
For example, in the CounterElement
example, an effect is defined to log the current count and its square whenever either of them changes:
count
or countSquared
changes, logging the new values to the console. This can be particularly useful for debugging.
Effects can also be used to emit custom events after specific state changes. For instance, you could emit a custom event whenever the count reaches a certain value. Here’s a great essay on this topic: Hypermedia-Friendly Scripting
ReactiveElement Methods:
observable(initialValue)
: Defines an observable property with an initial value. Returns an object withvalue
property andupdate
method.observableProp(propName, parseFn)
: Creates an observable property from an attribute.propName
is the name of the property andparseFn
is the function to parse the attribute value. It defaults to identity function if not provided.subscribe(key, store)
: Subscribes to a store and links it to an observable property. Returns the observable.computed(computeFn)
: Defines a computed property that depends on other observables. Returns an object with avalue
getter.effect(effectFn)
: Defines an effect that is triggered when an observable changes. The effect function can optionally return a cleanup function.dispatch(action, payload)
: Dispatches an action to the store.template()
: A method that should be implemented to return the template to be rendered.connectedCallback()
: Lifecycle method called each time the element is added to the document. Sets up initial state and triggers initial rendering.disconnectedCallback()
: Lifecycle method called each time the element is removed from the document. Cleans up listeners and effects.adoptedCallback()
: Lifecycle method called each time the element is moved to a new document. Can be used to reset or reinitialize internal state.attributeChangedCallback(name, oldValue, newValue)
: Lifecycle method called when an attribute of the element is added, removed, or changed. Useful for reacting to changes in attributes.static get observedAttributes()
: Static getter that returns an array of attribute names to monitor for changes. Used in conjunction withattributeChangedCallback
.
Note: Lifecycle methods are part of the Light DOM. We do not implement the Shadow DOM in this library. While Shadow DOM provides style and markup encapsulation, there are drawbacks if we want this library to interoperate with other libs.
createStore(initialState)
The createStore
function is a fundamental part of Cami.js. It creates a new store with the provided initial state. The store is a singleton, meaning that if it has already been created, the existing instance will be returned. This store is a central place where all the state of your application lives. It’s like a data warehouse where different components of your application can communicate and share data.
This concept is particularly useful in scenarios where multiple components need to share and manipulate the same state. A classic example of this is a shopping cart in an e-commerce application, where various components like product listing, cart summary, and checkout need access to the same cart state.
The store follows a flavor of the Flux architecture, which promotes unidirectional data flow. The cycle goes as follows: dispatch an action -> update the store -> reflect changes in the view -> dispatch another action. In addition, as we adhere to many of Redux’s principles, our store is compatible with the Redux DevTools Chrome extension, which allows for time-travel debugging.
Parameters:
initialState
(Object): The initial state of the store. This is the starting point of your application state and can be any valid JavaScript object.
Returns:
A store object with the following methods:
state
: The current state of the store. It represents the current snapshot of your application state.subscribe(listener)
: Adds a listener to the store. This listener is a function that gets called whenever the state changes. It also returns an unsubscribe function to stop listening to state changes.register(action, reducer)
: Adds a reducer to the store. A reducer is a function that knows how to update the state based on an action.dispatch(action, payload)
: Adds an action to the dispatch queue and starts processing if not already doing so. An action is a description of what happened, and the payload is the data associated with this action.use(middleware)
: Adds a middleware to the store. Middleware is a way to extend the store’s capabilities and handle asynchronous actions or side effects.
html
The html
function in Cami.js is a tagged template literal, based on lit-html, that allows for the creation of declarative templates. It provides several powerful features that make it effective in the context of Cami.js:
- Event Handling: It supports event handling with directives like
@click
, which can be used to bind DOM events to methods in your components. For example:
increment
method is called when the button is clicked.
- Attribute Binding: It allows for attribute binding, which means you can dynamically set the attributes of your HTML elements based on your component’s state. For example:
isActive
property of the component.
- Composability: It supports composability, which means you can easily include one template inside another. For example:


Sign Up to Our Newsletter
Be the first to know the latest updates
Whoops, you're not connected to Mailchimp. You need to enter a valid Mailchimp API key.