azero
A lightweight front-end JavaScript framework with built-in state management. The syntax is similar to htm used with vhtml. azero
is more than just the syntax! It is the whole runtime transforming your syntax. Without a Virtual Tree.
Table Of Contents
- azero
Installation
The use of Vite with vanilla-ts
(TypeScript template) and TailwindCSS is strongly recommended.
npm i azero
pnpm i azero
After that, update your dependencies.
npm update
pnpm up
JSX-Like Syntax
azero
uses JSX-like syntax like htm
does. This is powered by tagged templates and works in any modern browser. This is the counter example shown in every modern framework in azero
:
const counter = new Observable(0);
document.body.append(...html`
<div class="flex w-screen h-screen justify-center items-center text-gray-600 bg-white">
<button
onclick=${() => counter.value++}
class="px-4 py-2 bg-gray-100 font-semibold
rounded-full border border-gray-200 hover:border-gray-500 transition"
>
You have clicked me ${counter}
${counter.derive(count => count === 1 ? "time" : "times")}
</button>
</div>
`);
After this README you will be able to understand and fully recreate the example above.
Inserting
Inserting Values
You can insert any value into the dom; it will be converted to a string:
const x = 23;
document.body.append(...html`
<div>${"a string"}</div>
<div>${true}</div>
<div>${x}</div>
`);
Inserting Nodes
You can also insert types Node
and Node[]
:
const myNodes = html`
<div>Foo</div>
<div>Bar</div>
`;
document.body.append(...html`
<div>${myNodes}</div>
`);
Inserting Observable Values
You can insert observable values easily:
const counter = new Observable(0);
document.body.append(...html`
<button onclick=${() => counter.value++}>You have clicked me ${counter} times</button>
`);
Inserting Observable Nodes
You can also insert types Observable<Node>
and Observable<Node[]>
. Because this is rarely done there is not a recommended use case example. Furthermore, when using Observable<Node>
it is required that the initial value of the Observable
is of type Node
and not null
or undefined
. That is due to the runtime exchanging the nodes. If you want such a functionality nonetheless, you could toggle a hidden
class on the element or use an Observable<Node[]>
where - initially - you assign an empty array and then - when you update - assign a new array containing your new node.
Read more about using
Observable
with mutable data under Mutable Data
Attributes
azero
supports a rich attribute syntax:
document.body.append(...html`
<div title="Foo">Bar</div>
<div title='Foo'>Bar</div>
<div title=Foo>Bar</div> <!-- not recommended -->
`);
Internally the attribute is setting the property of the node (key) to the given value. class
keys are automatically converted to className
. Here is an example:
document.body.append(...html`
<div title="Foo">Bar</div>
`);
// azero (index.js)
// ...
const node = document.createElement("div");
node["title"] = "Bar";
// ...
Attributes on Components
Attributes on components are collected on an object that will be given to the function at invocation. The property children will contain an array of child nodes.
function MyComponent({something, children}: {something: number, children: Node[]}) {
return html`
<div>
something: ${something}
${children}
</div>
`;
}
document.body.append(...html`
<${MyComponent} title="Foo">Bar<//>
`);
Inserting Values and Observables
You can also insert values and observables as attributes:
const x = "Hello";
const toggledClass = new Observable("hidden");
// ... update `toggledClass` ...
document.body.append(...html`
<div title=${"Foo"}>Bar</div>
<div title=${x}>Zen</div>
<div class="${toggledClass}">Zen</div> <!-- the same effect -->
<div class='${toggledClass}'>Zen</div> <!-- the same effect -->
<div class=${toggledClass}>Zen</div> <!-- the same effect -->
`);
If an Observable
is inserted, it auto-subscribes to that Observable
and sets the attribute on update.
Inserting Values and Observables Into Strings
You can also insert values and observables into attribute strings:
const myStaticClass = "foo";
const toggledClass = new Observable("hidden");
// ... update `toggledClass` ...
document.body.append(...html`
<div class="bg-indigo-600 ${toggledClass} ${myStaticClass} etc...">Bar</div> <!-- the same effect -->
<div class='bg-indigo-600 ${toggledClass} ${myStaticClass} etc...'>Bar</div> <!-- the same effect -->
`);
This is implemented using the reduce
function.
Spread Props
Spread props are similar to JSX. All properties of the specified object are assigned to the element / component props.
document.body.append(...html`
<div ...${{title: "Foo"}}>Bar</div>
`);
Components
Components are - similar to React - functions returning reusable UI and logic. If the function is being used as a component, it may only return types Node
and Node[]
.
function Component({children}: {children: Node[]}) {
return html`
<div title="Foo">
Bar
${children}
</div>
`;
}
azero
gives you many ways to insert a component.
document.body.append(...html`
<${Component}/>
<${Component}>Children</${Component}>
<${Component}>Children<//>
${Component({children: []})} <!-- not recommended -->
${Component({children: [document.createTextNode("Children")]})} <!-- not recommended -->
`);
State Management
azero
gives you excellent built-in support for state management. The module azero/observable
contains those classes:
Observable<T>
ObservableArray<E>
ObservableMap<T>
Note: This module can be used without using azero core.
Observable
This concept is similar to React
useState()
and SvelteStore
.
Observable<T>
is a wrapper for a generic value T
; primarily used with primitives. You can create an Observable
with:
const observable = new Observable(0);
The type (in this case number
) will be inferred by TypeScript.
Changing the value
You can get / set the value through the ES6 class getter / setter property Observable.value
:
observable.value = 23; // set
console.log(observable.value); // get
Subscribing
This is where the magic happens. You can call subscribe
on observable
and the callback function provided is called whenever the value changes. This also returns another function, that unsubscribes the subscriber (when called).
const unsubscribe = observable.subscribe(value => console.log(value), false);
observable.value = 20; // logs `20`
// later
unsubscribe();
When initially subscribing, the second parameter (assigned to false
in the example) is a boolean specifying whether the callback should be called when initially subscribing. This parameter will default to true
. This is especially useful if you are synchronizing data and want to set an initial value to your target. In other words: if you were to remove that false
in the invocation, it would log 20
on the subscribe line.
Deriving
The Observable.derive
function creates a new Observable
that can be derived / generated / produced from the current:
const message = new Observable("Hello!");
const messageInUppercase = message.derive(message => message.toUpperCase());
Whenever message
updates, messageInUppercase
updates too and will contain the uppercase version of the message. Another frequent use case is toggled content:
const hidden = new Observable(false);
document.body.append(...html`
<button onclick=${() => hidden.value = !hidden.value}>Toggle</button>
<div class="${hidden.derive(hidden => hidden ? "hidden" : "")} bg-blue-600 ...">Some content</div>
`);
The string is derived from the boolean and inserted into the attribute.
Mutable Data
The use of Observable
s with objects and arrays for reactivity is discouraged, because - internally - it compares the assigned value and the existing value with ===
. So when the same object is present, ===
does not care if it was modified, it has to be a different object. But it has great application for static content:
const split: Observable<string[]> = new Observable([]);
let input = "";
document.body.append(...html`
<input oninput=${(e: Event) => input = (e.target as HTMLInputElement).value}>
<button onclick=${() => split.value = input.split("")}>Split!</button>
${split.derive(split => split.map(char => html`
<div>Char: ${char}</div>
`[1]))}
`);
In the example the split
Observable<string[]>
is derived from the user input (not directly, but when the user presses the button, the split string is assigned to split
). Now whenever split
updates, the split
array (in the provided arrow function) is mapped to an array of nodes. Note, that because html
returns Node[]
and you are mapping string
to Node
not Node[]
, the trailing [1]
must be added. This captures the <div/>
. Even if there is no whitespace before the <div/>
, there is always a text node. If you did not capture the index 1 of the array, Observable<string[]>
would be mapped to Observable<Node[][]>
(which the runtime cannot resolve).
Note, that those nodes will always be re-rendered when
split
changes. Also, it relies on the creation of new objects. If you still want a reactive array, check out the next section.
ObservableArray
The ObservableArray<E>
is a wrapper around an array of E
. It has a completely different set of functions than Array
.
const numbers = new ObservableArray([10, 20]); // You can provide an initializer array
numbers.add(30);
numbers.remove(10);
// ...
In case you did not provide an initializer array you will need to annotate the type, because TypeScript will not be able to infer the type:
const numbers = new ObservableArray<number>();
// ...
Atomic Operations
Any operation done by its interface comes down to those four atomic operations:
-
set(index: number, element: E)
: Sets the index to the element (overriding / replacing) -
insert(index: number, element: E)
: Inserts the element at the specified index -
removeAt(index: number)
: Removes the element at the specified index -
move(from: number, to: number)
: Moves the element at indexfrom
to indexto
Subscribing
You can subscribe to any of those operations and therefore are able to mirror the ObservableArray
on your own array:
subscribeToSet(subscription: (index: number, element: E) => void): () => void
subscribeToInsert(subscription: (index: number, element: E) => void): () => void
subscribeToRemoveAt(subscription: (index: number, element: E) => void): () => void
subscribeToMove(subscription: (from: number, to: number) => void): () => void
All of those functions return a function that - when called - unsubscribes your subscription.
Map
The map
functions provides a way to integrate your ObservableArray
into the dom:
map<E>(array: ObservableArray<E>, mapper: (element: E, index: number) => Node): Node[]
It maps each element to a node, so you can easily integrate your data. Internally, it subscribes to all atomic operations and therefore mirrors changes to the array
with the dom. When nodes are added / removed, only new nodes are rendered. An example:
type Post = {
title: string,
content: string
}
const posts = new ObservableArray<Post>([
{
title: "Initial Post",
content: "Initial Content"
}
]);
document.body.append(...html`
<button onclick=${() => posts.add({
title: "A Post",
content: "Lorem Ipsum"
})}>Add Post</button>
${map(posts, post => html`
<div>
<h1>
${post.title} -
<button onclick=${() => posts.remove(post)}>Delete Post</button>
</h1>
<div>${post.content}</div>
</div>
`[1])}
`);
Here the posts
ObservableArray
contains an initial post. New posts are added through posts.add({...})
. Each post maps to a visual representation of the post data, including a button that uses posts.remove(post)
to remove the post. Simple.
Reduce
This section technically belongs to
Observable
.
This concept is similar to the Svelte
$: ...
syntax.
The reduce
function combines an array of Observable
s into one new Observable
which can be derived / generated / produced from all the specified ones. This function could have easily been called derive but in a sense it is similar to the reduce operation on an array, so reduce
was kept. A frequent use case is:
const a = new Observable(0);
const b = new Observable(0);
document.body.append(...html`
<div>${a} * ${b} = ${reduce([a, b], ([a, b]) => a * b)}</div>
`);
The result of a * b
is dependent on both Observable
s a
and b
. When each of them changes, the reducer (function) will be called to recalculate the result.
Note, that types are not available in reduce
because reduce
accepts variable type parameters.
Lifecycle
Components (normally) have this lifecycle:
Mounting
-> Updating
-> Unmounting
Because nodes and components in azero
are updated on node-level, the Updating
step is negligible for the component. Both Mounting
and Unmounting
happen on node-level too. azero
gives you two optional properties
Node.onMount: () => void
Node.onUnmount: () => void
on every node, that will be called when this node is added / removed from the dom.
Automatic Lifecycle
When inserting an Observable
, azero
will subscribe on mount and unsubscribe on unmount, so that this node is not updated when it isn't being used. Example (a clock):
const time = new Observable(new Date());
let interval: number;
document.body.append(...html`
<div
onMount=${() => interval = setInterval(() => time.value = new Date(), 1000)}
onUnmount=${() => clearInterval(interval)}
>
Time: ${time}
</div>
`);
You're done! Start making some apps!