The fast, 3KB JavaScript framework for "echoing" reactive UI with tagged templates, inspired by Hypertext Literal.
- Fast - no virtual DOM, no extensive diffing, pure fine-grained reactivity
- Small - no transpiling or compiling, zero dependencies, 3KB (gzip)
- Simple - as simple as innerHTML
Note
The current release is merely a proof of concept and is not ready for production. The next branch is implementing the new proposal API for production use. Feel free to join the discussion and contribute!
EchoX is typically installed via a package manager such as Yarn or NPM.
$ npm install echox
EchoX can then imported as a namespace:
import * as X from "echox";
const node = X.html`<define count=${X.state(0)}>
<button @click=${(d) => d.count++}>π</button>
<button @click=${(d) => d.count--}>π</button>
<span>${(d) => d.count}</span>
</define>`;
document.body.append(node);
EchoX is also available as a UMD bundle for legacy browsers.
<script src="https://cdn.jsdelivr.net/npm/echox"></script>
<script>
const node = X.html`...`;
document.body.append(node);
</script>
Reading the core concepts to learn more:
- Template Interpolations
- State Bindings
- Class and Style Bindings
- Event Handling
- List Rendering
- Conditional Rendering
- Effect
- Ref Bindings
- Component
- Composable
- Store
EchoX uses tagged template literal to declare UI, which renders the specified markup as an element.
import * as X from "echox";
const node = X.html`<h1>hello world</h1>`;
document.body.append(node);
A string, boolean, null, undefined can be interpolated to an attribute:
// Interpolate number attribute.
X.html`<h1 id=${"id" + Math.random()}></h1>`;
// Interpolate boolean attribute.
X.html`<input checked=${true}></input>`;
If the interpolated data value is a node, it is inserted into the result at the corresponding location.
X.html`<h1>${document.createText('hello world')}</h1>
It is also possible to interpolate iterables of nodes into element.
X.html`<ul>${["A", "B", "C"].map((d) => X.html`<li>${d}</li>`)}</ul>`;
# X.html(markup, ...interpolations)
If only one arguments is specified, render and return the specified component. Otherwise tenders the specified markups and interpolations as an element.
For stateful UI, a wrapped define tag is required for defining some properties related to reactivity. Each state-derived property or child node should be specified as a callback, which is invoked on an object containing all the properties defined on the define tag.
// state-derived property
X.html`<define count=${X.state(0)}>
<span>${(d) => d.count}</span>
<button @click=${(d) => d.count++}></button>
</define>`;
// state-derived child node
X.html`<define color=${X.state(0)}>
<span style=${(d) => `background: ${d.color}`}>hello</span>
<input value=${(d) => d.color} @input=${(d, e) => (d.color = e.target.value)} />
</define>`;
If a state is non-primitive, it should be specified as a callback returning the state.
// array state
X.html`<define letters=${X.state(() => ["A", "B", "C"])}></define>`;
// object state
X.html`<define info=${X.state(() => ({name: "jim", age: 22}))}></define>`;
A computed state also can be specified as a callback, calling on all reactive properties and return a new state.
X.html`<define message=${X.state("hello")} reversed=${X.state((d) => d.message.split("").reverse().join(""))}>
<input value=${(d) => d.message} @input=${(d, e) => (d.message = e.target.value)} />
<p>Reversed: ${(d) => d.reversed}</p>
</define>`;
Please notes that the name of a reactive property must be kebab case in defined tag, but convert to camel case when accessing.
X.html`<define must-kebab-case=${X.state("hi")}>${(d) => d.museKebabCase}</define>`;
# X.state(value)
Returns a state.
Class and style are just like other properties:
X.html`<define random=${Math.random()} >
<span
class=${(d) => (d.random > 0.5 ? "red" : null)}
style=${(d) => (d.random > 0.5 ? `background: ${d.color}` : null)}
>
hello
</span>
</define>`;
But X.cx and X.css make it easier to style conditionally . With them, now say:
X.html`<define random=${Math.random()} >
<span
class=${(d) => X.cx({red: d.random > 0.5})}
style=${(d) => X.css(d.random > 0.5 && {background: d.color})}
>
hello
</span>
</define>`;
Multiple class objects and style objects can be specified and only truthy strings will be applied:
// class: 'a b d'
// style: background: blue
X.html`<define>
<span class=${X.cx(null, "a", undefined, new Date(), {b: true}, {c: false, d: true, e: new Date()})}> Hello </span>
<span style=${X.css({background: "red"}, {background: "blue"}, false && {background: "yellow"})}> World </span>
</define>`;
# X.cx(...classObjects)
Returns a string joined by all the attribute names defined in the specified classObjects with truthy string values.
# X.css(...styleObjects)
Returns a string joined by all the attributes names defined in the merged specified styleObjects with truthy string values.
Using @ directive to bind a event with the specified event handler, which is calling on all reactive properties and native event object.
X.html`<define count=${X.state(0)}>
<button @click=${(d) => d.count++}>π</button>
<button @click=${(d) => d.count--}>π</button>
<span>${(d) => d.count}</span>
</define>`;
// Event is the second parameter.
X.html`<define color=${X.state(0)}>
<span style=${(d) => `background: ${d.color}`}>hello</span>
<input value=${(d) => d.color} @input=${(d, e) => (d.color = e.target.value)} />
</define>`;
Memorized list rendering is achieved by for tag. The each property is required in for tag to specify the iterable state. Some rest item parameters are called on the stateful binds in for tag, accessing item and index by item.$value and item.$index respectively.
X.html`<define dark=${state(false)} blocks=${state([1, 2, 3])}>
<ul>
<for each=${(d) => d.blocks}>
<li>${(d, item) => item.$index}-${item.$value}</li>
</for>
</ul>
</define>`;
Memorized conditional rendering is achieved by if, elif and else tags. The expr property is required in if and elif tags, displaying the child nodes with the specified callback evaluating to true.
X.html`<define count=${state(0)} random=${state(Math.random())}>
<if expr=${(d) => d.random < 0.3}>
<span>A</span>
</if>
<elif expr="${(d) => d.random < 0.6}}">
<span>B</span>
</elif>
<else>
<span>C</span>
</else>
</define>`;
Effects can be defined using X.effect, which is be called before DOM elements are mounted and after dependent states are updated. An optional callback can be returned to dispose of allocated resources.
const f = (d) => ("0" + d).slice(-2);
X.html`<define
date=${X.state(new Date())}
${X.effect(() => console.log(`I'm a new time component.`))}
${X.effect((d) => {
const timer = setInterval(() => (d.date = new Date()), 1000);
return () => clearInterval(timer);
})}
>
<span>${({date}) => `${f(date.getHours())}:${f(date.getMinutes())}:${f(date.getSeconds())}`}</span>
</define>`;
# X.effect(effect)
Returns a effect.
Accessing a DOM element in effect.
X.html`<define
div=${X.ref()}
${X.effect((d) => d.div && (d.div.textContent = "hello"))}
>
<div ref="div"></div>
</define>`;
# X.ref()
Returns a ref.
A component can be defined a component using X.component and registered it in define tag. Please notes that the name of component should always be kebab case.
const ColorLabel = X.component`<define color=${X.prop("steelblue")} text=${X.prop()}>
<span>${(d) => d.text}</span>
</define>`;
X.html`<define color-label=${ColorLabel}>
<color-label color="red" text="hello world"></color-label>
</define>`;
A component can be rendered directly by X.html.
const App = component`<define count=${state(0)}></define>`;
html(App);
# X.component(markup, ...interpolations)
Returns a component.
# X.prop([defaultValue])
Returns a prop.
Some reusable logic can be defined using X.composable and accessed through the specified namespace.
const useMouse = X.composable`<define
x=${X.state(0)}
y=${X.state(0)}
log=${X.method((d) => console.log(d.x, d.y))}
${X.effect((d) => {
const update = ({clientY, clientX}) => ((d.x = clientX), (d.y = clientY));
window.addEventListener("mousemove", update);
return () => (window.removeEventListener("mousemove", update), console.log("remove"));
})}>
</define>`;
X.html`<define mouse=${useMouse}>
<button @click=${(d) => d.mouse.log()}>Log</button>
<span>${(d) => `(${d.mouse.x}, ${d.mouse.y})`}</span>
</define>`;
# X.composable(strings, ...interpolations)
Returns a reusable composable.
A global and single-instance store can be defined using X.store and accessed through the specified namespace.
// store.js
export createStore = store`<define
value=${state(0)}
increment=${method((d) => d.value++)}
decrement=${method((d) => d.value--)}>
</define>`;
// counter.js
import {createStore} from "./store.js";
X.component`<define counter=${createStore()}>
<button @click=${(d) => d.counter.count++}>π</button>
<button @click=${(d) => d.counter.count--}>π</button>
<span>${(d) => d.counter.count}</span>
</define>`;
# X.store(strings, ...interpolations)
Returns a global and single-instance store.