How it Works: Observable Properties, Observable State, and Observable Proxy
Foundational Concepts
Before we do a deep dive on how Cami works, we need to understand a few foundational ideas first. I suggest reading the following links before continuing:
Observable Properties
Observable Properties are a key idea in Cami, building upon the foundational concepts mentioned above. These properties belong to instances of the ReactiveElement class and are automatically monitored for any changes.
When a change is detected in an Observable Property (e.g. this.count++
), the ReactiveElement instance is informed and triggers a re-rendering of the template()
method's return value. This template method returns a template literal (e.g. <p>Count: ${this.count}</p>
), which is then parsed then rendered to the DOM.
If you're familiar with React, you can think of tagged template literals as similar to JSX. The rendering process, akin to React's render()
method, is automatically handled by the ReactiveElement instance when an Observable Property changes. This automatic re-rendering is a manifestation of the Observer Pattern, where the ReactiveElement instance acts as the observer, reacting to changes in the Observable Properties.
Counter Example (Observable Properties & Primitive Values)
Here is an example of an Observable Property in a ReactiveElement class:
class CounterElement extends ReactiveElement {
count = 0
template() {
return html`
<button @click=${() => this.count--}>-</button>
<button @click=${() => this.count++}>+</button>
<div>Count: ${this.count}</div>
`;
}
}
Counter Demo
How it works
Under the hood, the Observable Property is created using Object.defineProperty
with a getter and setter. The getter function returns the current value of the property. If the property is a primitive value, this will return the value directly from the ObservableState instance. If the property is a non-primitive value, this will return an ObservableProxy that wraps the ObservableState instance. This polymorphic behavior allows the ObservableProperty to handle both primitive and non-primitive values, and handle nested properties.
The setter function updates the value of the property. It updates the ObservableState instance with the new value. This setter is used when assigning a new value to the property on a ReactiveElement instance. When the setter is called, it triggers a re-render of the component.
In our example earlier, count
is an Observable Property. Any changes to count
will automatically trigger a re-render of the component.
How it works (More Intuitively)
That's a lot of abstractions, but you can think of it this way:
- For a given primitive (e.g. number, string, boolean), this gets converted into an ObservableState instance. ObservableState instances are wrapper objects with
.value
getters/setters. And to make it easier to use, we wrap the ObservableState instance with an ObservableProperty, which is just anObject.defineProperty
with a getter and setter, you can then just callthis.count
to get the value, andthis.count = 1
to set the value. - For a given non-primitive (e.g. object, array), this gets converted into an ObservableState instance. ObservableState instances are wrapper objects with
.value
getters/setters. And to make it easier to use, we wrap the ObservableState instance with an ObservableProxy, which is just a Proxy object with a getter and setter, you can then just callthis.todos
to get the value, andthis.todos = []
to set the value. The reason we use Proxy instead of Object.defineProperty is because Proxy allows us to handle nested properties.
Task Manager Example (Observable Properties & Non-Primitive Values)
Observable Properties can also handle non-primitive values and nested properties. This is achieved by returning an ObservableProxy instead of the value directly. The ObservableProxy wraps the ObservableState instance and allows for nested properties to be observed. Here is an example:
class TaskManagerElement extends ReactiveElement {
tasks = [];
// ...other methods...
template() {
// ...template code...
}
}
In this example, tasks
is an Observable Property that is an array. Any changes to the tasks
array or any of its elements will automatically trigger a re-render of the component.
Todo List (In-Memory State)
Below is the API Reference for Observable Properties. Updated version is at the API Reference.
ObservableProperty
Kind: global typedef Properties
Name | Type | Description |
---|---|---|
get | function |
A getter function that returns the current value of the property. If the property is a primitive value, this will return the value directly from the ObservableState instance. If the property is a non-primitive value, this will return an ObservableProxy that wraps the ObservableState instance. This getter is used when accessing the property on a ReactiveElement instance. This polymorphic behavior allows the ObservableProperty to handle both primitive and non-primitive values, and handle nested properties (only proxies can handle nested properties, whereas getters/setter traps cannot) |
set | function |
A setter function that updates the value of the property. It updates the ObservableState instance with the new value. This setter is used when assigning a new value to the property on a ReactiveElement instance. |
Example
// this.count is an ObservableProperty, where if you get the value, it returns the current value of the property, and if you set the value, it updates the property with the new value
// ObservableProperty is just Object.defineProperty with a getter and setter, where the Object is the ReactiveElement instance
class CounterElement extends ReactiveElement {
count = 0
template() {
return html`
<button @click=${() => this.count--}>-</button>
<button @click=${() => this.count++}>+</button>
<div>Count: ${this.count}</div>
`;
}
}
// this.query returns an ObservableProperty / ObservableProxy
// this.todos is an ObservableProxy, where if you get the value, it returns the current value of the property, and if you set the value, it updates the property with the new value
// We use Proxy instead of Object.defineProperty because it allows us to handle nested properties
class TodoListElement extends ReactiveElement {
todos = this.query({
queryKey: ['todos'],
queryFn: () => {
return fetch("https://api.camijs.com/todos?_limit=5").then(res => res.json())
},
staleTime: 1000 * 60 * 5 // 5 minutes
})
template() {
// ...template code...
}
}
// this.tasks is an ObservableProxy, where if you get the value, it returns the current value of the property, and if you set the value, it updates the property with the new value
// We use Proxy instead of Object.defineProperty because it allows us to handle nested properties
class TaskManagerElement extends ReactiveElement {
tasks = [];
filter = 'all';
// ...other methods...
template() {
// ...template code...
}
}
ObservableState
Kind: global typedef Properties
Name | Type | Description |
---|---|---|
value | any |
The current value of the observable state. This is the value that is returned when accessing a primitive property on a ReactiveElement instance. It can also be used to set a new value for the observable state. |
update | function |
A function that updates the value of the observable state. It takes an updater function that receives the current value and returns the new value. This is used when assigning a new value to a primitive property on a ReactiveElement instance. It allows deeply nested updates. |
[dispose] | function |
An optional function that cleans up the observable state when it is no longer needed. This is used internally by ReactiveElement to manage memory. |
ObservableProxy
Kind: global typedef Properties
Name | Type | Description |
---|---|---|
get | function |
A getter function that returns the current value of the property. If the property is a primitive value, this will return the value directly from the ObservableState instance. If the property is a non-primitive value, this will return an ObservableProxy that wraps the ObservableState instance. This getter is used when accessing a non-primitive property on a ReactiveElement instance. We use Proxy instead of Object.defineProperty because it allows us to handle nested properties. |
set | function |
A setter function that updates the value of the property. It updates the ObservableState instance with the new value. This setter is used when assigning a new value to a non-primitive property on a ReactiveElement instance. |