This library aims to provide a simple way to create reactive custom HTML elements to your HTML/js projects.
All project is made in typescript, but can be used in javascript projects.
It uses signals to handle updates exactly when and where they are needed.
It dont use shadow dom, that make the library pretty simple, and fast.
Lightweight, about 8.55kb, 2.25kb gzipped, 1.94kb brotli compressed actually.
Note: This library is not yet published to npm. This is just a placeholder for now. To use it in your project, you can install it from github:
npm install https://github.com/RsMan-Dev/reactive_custom_elements
npm install reactive-custom-elements
To get started, you need to import the library and extend the ReactiveCustomElement
class.
And register your element with customElements.define()
.
import ReactiveCustomElement from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
}
customElements.define('my-element', MyElement);
The ReactiveCustomElement
class provides the following lifecycle methods:
connected()
- Called when the element is added to the DOM.disconnected()
- Called when the element is removed from the DOM.render()
- Called after connected(), should only call this.createTree() and return the result.postRender()
- Called after render(), if any need to make something after rendering this element.
WARNING: the ReactiveCustomElement
class extends HTMLElement
, in typescript,
connectedCallback()
and disconnectedCallback()
are made private, but
still can be overridden in js, DO NOT override them, use the provided
lifecycle methods instead, connected() will be called instantly when
connectedCallback()
is called, and signals are created, and disconnected() will be called
instantly when disconnectedCallback()
is called.
The ReactiveCustomElement
class provides this.signal()
to create signals.
Signals are used to handle updates exactly when and where they are needed.
basic usage:
this.signal("initial_value");
Example:
import ReactiveCustomElement from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
count = this.signal(0);
connected() {
console.log(this.count.val); // 0
}
}
Signals can work together with other signals, to avoid declaring effects at any time.
this.signal()
can take a function as argument, the function will be wrapped in an effect,
so when a depending signal is updated, the function will be called, and the signal will be updated.
Example:
import ReactiveCustomElement from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
count = this.signal(0);
count2 = this.signal(0);
count3 = this.signal(() => this.count.val + this.count2.val);
connected() {
console.log(this.count3.val); // 0
this.count.val = 1;
console.log(this.count3.val); // 1
this.count2.val = 2;
console.log(this.count3.val); // 3
}
}
Signals can be direct dependants of other signals, these signals are synced together. Example:
import ReactiveCustomElement from 'reactive_custom_elements';
class MyParentElement extends ReactiveCustomElement {
count = this.signal(0);
}
class MyElement extends ReactiveCustomElement {
count = this.signal(() => this.parent.count);
get parent() {
const el = this.closest("my-parent-element");
if(!el) throw new Error("parent not found");
return el;
}
}
customElements.define('my-parent-element', MyParentElement);
customElements.define('my-element', MyElement);
document.body.innerHTML = "<my-parent-element><my-element></my-element></my-parent-element>";
const parent = document.querySelector("my-parent-element");
const child = document.querySelector("my-element");
console.log(parent.count.val); // 0
console.log(child.count.val); // 0
parent.count.val = 1;
console.log(parent.count.val); // 1
console.log(child.count.val); // 1
child.count.val = 2;
console.log(parent.count.val); // 2
console.log(child.count.val); // 2
NOTE: this is recommended to initialize dependant signals with a function, even if initializing directly with a signal works, because when the signal is initialized, parent may not be initialized yet, this problem often occurs when elements are created in javascript, and then added to the DOM.
Signals will call its dependents (explained later) when its value is updated.
note that signals are only updated when its setter is called, so if you want
to update a signal, you need to call its setter, or use signal.callDeps()
.
so if you want to save performance by not always doing a shallow copy of the
value, you can use signal.callDeps()
to update dependants without.
Example:
import ReactiveCustomElement from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
hello = this.signal({hello: "world"});
count = this.signal(0);
connected() {
this.hello.val.hello = "world2"; // this will not update dependants
this.hello.callDeps(); // this will update dependants
this.hello.val = {hello: "world3"}; // this will update dependants
this.count.val; // this will not update dependants
this.count.val = 1; // this will update dependants
this.count.val++; // this will update dependants
}
}
Signals<T>
provides these methods:
val: T
- The value of the signal. (getter and setter)callDeps(): void
- Call dependants of the signal.addDep(callback: () => void): void
- Add a callback to be called when the signal is updated.forgetDep(callback: () => void): void
- Remove a callback from the signal.omitDep(callback: () => void): void
- Set a callback to be ignored when the signal is updated.unomitDep(callback: () => void): void
- Remove a callback from the ignored callbacks.readonly parent: ReactiveCustomElement
- The parent element of the signal.identifier: object
- The identifier of the signal, used to identify the signal whendebug
is true. Attributes of the object:message: string
- Some infos about the identifier.var_name?: string
- The name of the variable into where the signal is stored. (got using files info on stack trace, may be not found)component?: string
- The name of the component where the signal was created.fromFile?: string
- The url of the file where the signal was created.fromLine?: number
- The line number of the file where the signal was created.fromColumn?: number
- The column number of the file where the signal was created.
Effects are used to call a function when a signal used in the function is updated.
Effects are created with this.effect()
, and are normally destroyed when the main
class passes into the garbage collector, feel free to make an issue if you find any
memory leaks.
Example:
import ReactiveCustomElement from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
count = this.signal(0);
connected() {
this.effect(() => {
console.log(this.count.val);
});
this.count.val = 1; // console => 1
this.count.val = 2; // console => 2
}
}
The ReactiveCustomElement
class provides this.attribute()
to bind element attributes
as signals.
this.attribute<T>()
takes 3 arguments:
name: string
- The name of the attribute to bind.parse?: (value: string) => T
- A function to parse the attribute value to the signal value.stringify?: (value: T) => string
- A function to stringify the signal value to the attribute value.
Example:
import ReactiveCustomElement from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
count = this.attribute("count",
(value) => parseInt(value),
(value) => value.toString()
);
connected() {
console.log(this.count.val); // 0
this.count.val = 1;
console.log(this.count.val); // 1
console.log(this.getAttribute("count")); // "1"
this.setAttribute("count", "2");
console.log(this.count.val); // 1 => this way, the attribute value is set
// asynchronusly, so the signal value is not
// updated yet, use effect to track attribute
console.log(this.getAttribute("count")); // "2"
}
}
Rendering will run only one time when the element is added to the DOM, only effects created during rendering will cause updates. so be sure you understand how signals and effects work before continuing, make also sure you understand when effects are created during rendering.
Rendering is done with this.createTree()
, it takes TagDescriptor
as argument.
AttributeMap
is an object with the following properties:
key?: string
- The key of the element to create.on<any string>: (event: Event) => void
- Event listeners.<any string>: () => any | any
- Attributes, if the value is a function, it will be wrapped in an effect, and the effect will be destroyed when the element is removed from the custom element children.
TagDescriptor
is an object with the following properties:
tag: string
- The tag name of the element to create.attrs: AttributeMap
- The properties of the element to create. properties starting withon
are considered event listeners.children: (() => TagDescriptorWithKey[] | TagDescriptor | string) | TagDescriptor | string
- The children of the element to create, if the value is a function, it will be wrapped in an effect, and the effect will be destroyed when the element is removed from the custom element children. if the return type of the function is an array, keys will be required for each element, to avoid sort of memory leaks, and to be sure we are updating the right element.
TagDescriptorWithKey
is an object with the following properties:
key: string
- The key of the element to create.- all other properties are the same as
TagDescriptor
.
TagDescriptor
can be made using the tag()
helper function, which takes as arguments:
tagName: string
- same astag
inTagDescriptor
.attrs: AttributeMap
- same asattrs
inTagDescriptor
....children: (() => TagDescriptorWithKey[] | TagDescriptor | string) | TagDescriptor | string
- The children of the element to create.
TagDescriptorWithKey
can be made using the keyedtag()
helper function, which takes as arguments:
key: string
- The key of the element to create.- all other arguments are the same as
tag()
.
TagDescriptorWithKey
can also be made using the tag()
helper function, with a key
property in the attrs
argument.
Now let's see some examples:
import ReactiveCustomElement, {tag} from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
count = this.signal(0);
render(){
return this.createTree(
tag(
"div", // tag name
{ // attributes
onclick: () => this.count.val++, // event listener, will increment count when clicked
"data-count": () => this.count.val, // attribute, will update when count is updated
"data-wrong": this.count.val, // attribute, will not update when count is updated
},
"count: ", // child, will add a static text node
() => this.count.val, // child, will add a dynamic text node that will update when count is updated
() => Array(this.count.val % 10) // child, will add all elements in the array, will update when count is updated
.fill(0)
.map((_, i) => tag( // child, will add a div for each count % 10
"div", // tag name
{ // attributes
key: i.toString(), // key, required for each element in an array, elements with same key will be replaced only if the tag name is not the same
},
"i: " + i + "count: ", // child, will add a static text node
() => this.count.val, // child, will add a dynamic text node that will update when count is updated
))
)
);
}
}
Jsx is supported, but no fragment support yet, as i wanted to allow using
other libraries like react, solid, etc, the way you use jsx in this library
is telling jsx in each file to use the tag()
function above import statements.
this works with my personal tests, using --jsx: react | preserve, feel free
to make an issue if you find any problems, or make a pull request if you want
to add fragment support, or any other feature. the above example can be written
like this:
/** @jsx tag */ // => can be omitted if jsxFactory is set to "tag" into config
import ReactiveCustomElement, {tag} from 'reactive_custom_elements';
class MyElement extends ReactiveCustomElement {
count = this.signal(0);
render(){
return this.createTree(
<div
onclick={() => this.count.val++}
data-count={() => this.count.val}
data-wrong={this.count.val}
>
count: {() => this.count.val}
{() => Array(this.count.val % 10)
.fill(0)
.map((_, i) => (
<div key={i.toString()}>
i: {i} count: {() => this.count.val}
</div>
))}
</div>
);
}
}
Note: functions are still required to tell the library there is an effect.
Note: You can add custom elements in tag, or jsx, but in typescript, this will need to
register the custom element into HTMLElementTagNameMap
:
declare global { interface HTMLElementTagNameMap { "my-element": MyElement } }
The ReactiveCustomElement
class contains a debug
property, which is a boolean.
as soon as it is set to true, the library will log many debug infos to the console.
it is recommended to set it to true only when needed, as it will slow down the library,
and spam an absurd amount of logs to the console.