A lightweight library for creating reactive web components with state management, declarative templates, and seamless store integration. Built on Zustand and Immer for optimal state management.
Think of it as "React-at-home" - if your home is a 6kB JS script - or "a less-opinionated AlpineJS" that doesn't break CSP.
- 🔧 Declarative Templates - Use Mustache-like syntax for dynamic content
- ⚡ Reactive State - Automatically re-render parts of HTML when state changes
- 🔄 State Management - Global Zustand-powered store integration with Immer
- 🎯 Event Handling - Declarative event listener setup
- 🔁 Loop Rendering - Built-in
for-loopdirective for repetitive content - 🎨 Shadow DOM - Optional shadow DOM support for style encapsulation
- 🏷️ TypeScript - Fully typed with comprehensive type definitions
- 📦 Reactive Props - Component attributes automatically sync with state
- 🧩 JSON Support - Serialize/deserialize objects in templates and attributes
npm install zef-componentsZef-components depends on:
- Zustand (^5.0.8) for state management
- Immer (^10.1.3) for immutable state updates
Define your component:
const counterHTML = `
<div>
<h2 re-render="count">Count: {{count}}</h2>
<button id="inc">Increment</button>
</div>
`;
Factory("my-counter", counterHTML, {
state: { count: 0 },
eventListener: {
"#inc": [
{
event: "click",
handler() {
this.state.count++;
},
},
],
},
});Build your bundle with esbuild and import it in your html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script type="module" src="./index.js"></script>
</head>
....
</html>Use it in your HTML:
<body>
<my-counter></my-counter>
</body>Reference any properties of the component's state or store using Mustache syntax:
{{propertyName}} {{property.nestedProperty}}Values are rendered as follows:
- For primitive values:
state.propertyName?.toString() - For functions:
state.propertyName?.call()?.toString()
Serialize objects in templates using the json:: prefix:
<div>
<p>User data: {{json::user}}</p>
<p>Config: {{json::config}}</p>
</div>Use ternary operators for conditional rendering:
<div style="display:{{open?:'none'}};"></div>Notes:
- True/false values must be strings delimited by
',", or backticks - Both expressions are optional (default to empty strings)
- Quotes are removed during rendering
Specify dependencies for reactive updates:
<div re-render="date,person.age"></div>This element will re-render when:
- The modified property path starts with a dependency
- A dependency starts with the modified property path
Generate HTML through iteration:
<div for-loop="item in collection">{{item.name}}</div>
<div for-loop="item of collection">{{item.name}}</div>Supported collection types:
- Numbers: iterates from 0 to collection-1
- Arrays: iterates through each item
Note: Avoid using re-render on or inside for-loop directives. For complex reactivity, consider creating separate components.
Component attributes can automatically sync with state:
const userCardHTML = `
<div>
<h2>{{userName}}</h2>
<p>Age: {{userAge}}</p>
<p>Hobbies: {{userHobbies.name}}</p>
</div>
`;
Factory("user-card", userCardHTML, {
props: {
userName: "name", // Maps to 'name' attribute
userAge: "age", // Maps to 'age' attribute
userHobbies: "json::hobbies", // JSON-parsed attribute
},
});
/*in your html html*/Usage in HTML:
<user-card name="john" age="27" hobbies="{name:'soccer'}"></user-card>;Notice that the hobbies attribute use ' as quote (which is not the way the default JSON.stringify function works).
The library exports two functions that will help you deal with this json formatting :
export declare function parseJSON(json: string | null): any;
export declare function marshallJSON(obj: any): string;Use these methods to query elements within your component:
this.$(css)- Alias forquerySelectorthis.$$(css)- Alias forquerySelectorAll
Hook into component lifecycle events:
Factory("my-component", html, {
onMount() {
// Fired after component is inserted into DOM
// If you need attribute to initialize your
// component's state this is where you do it.
},
onUnmount() {
// Fired when component is removed from DOM
},
onRender(path) {
// Fired after re-rendering, with the property path that triggered it
// Warning: Can cause infinite loops if not handled carefully
},
onAttributeChanged(name, oldValue, newValue) {
// Fired when observed attributes change
},
observedAttributes: ["data-value"], // Required for attribute change detection
});Components implement an HTMLInput-like interface:
Factory("wc-test", html, {
state: { count: 15 },
value() {
return this.state.count; // Updates component value on state changes
},
});The input event is only triggered when the computed value changes.
Components use Shadow DOM by default for style encapsulation:
Factory("my-component", html, {
noShadowRoot: false, // Default, enables Shadow DOM
});
Factory("inline-component", html, {
noShadowRoot: true, // Disables Shadow DOM for inline reactivity
});When using Shadow DOM:
- Styles are encapsulated within the component
- Use
<slot>elements to project light DOM content - Set
noShadowRoot: truefor small reactive HTML blocks
Declare event listeners in options or programmatically:
// Option-based declaration
Factory("my-component", html, {
eventListener: {
".btn": [
{
event: "click",
handler() {
/* ... */
},
options: { once: true },
},
],
},
});
// Programmatic registration
this.$$on(".btn", {
event: "click",
handler() {
/* ... */
},
});
// Deregistration
this.$$off(".btn", "click");Note: Because a re-render will replace the element outer HTML, any event listener attached with addEventListener will be lost.
Access and manage global state with Zustand and Immer:
// External store access
import { globalStore } from "zef-components";
globalStore.setState({ user: { name: "John" } });
const html = `
<h1>{{localUser.name}}</h1>
`;
// Component store binding
Factory("user-display", html, {
storeListener: {
localUser: "user", // Maps store.user to component state
},
onMount() {
// Internal store access with Immer
this.$setStore((store) => {
store.user.name = "Jane"; // Immutable update with Immer
});
},
});| Option | Type | Description |
|---|---|---|
observedAttributes |
string[] |
Array of attributes to observe for changes |
value |
(this: WebComponent<T, K, L>) => any |
Function that returns the component's value |
state |
T | ((this: WebComponent<T, K, L>) => T) |
Component state object or factory function |
props |
L |
Prop binding configuration |
onMount |
(this: WebComponent<T, K>) => void |
Lifecycle hook called when component mounts |
onUnmount |
(this: WebComponent<T, K, L>) => void |
Lifecycle hook called when component unmounts |
onRender |
(this: WebComponent<T, K, L>, path: string) => void |
Hook called after re-rendering |
onAttributeChanged |
(this: WebComponent<T, K, L>, name: string, oldValue: string, newValue: string) => void |
Called when observed attributes change |
eventListener |
EventListenerRecord |
Event listener configuration |
storeListener |
K |
Store binding configuration |
noShadowRoot |
boolean |
Disable shadow DOM encapsulation |
This object inherits from all the methods of HTMLElement, including remove(), getAttribute()
| Property/Method | Description |
|---|---|
state |
Reactive state object |
value |
Component's current value |
root |
Root element (shadow root or element itself) |
$getStore() |
Returns current global store state |
$setStore(updater) |
Updates store using Immer producer function |
$(css) |
Query single element |
$$(css) |
Query multiple elements |
$$on(css, eventHandle) |
Register event listener |
$$off(css, event) |
Remove event listener |
reRenderProperty(pathname) |
Manually trigger re-render for specific property |
type GlobalStore = {
[key: string]: any;
};
type EventListenerRecord = Record<string, EventHandle[]>;
type EventHandle = {
event: string;
handler: (this: WebComponent, ev: Event) => void;
options?: EventListenerOptions;
};Keys of EventListenerRecord are CSS selectors.
EventHandle represents an event listener:
eventis the type of the event to listen to eg.clickhandleris the listener, it will be bound to the component, do not expectthisto be the event targetoptionsare the same as addEventListener
import Factory from "zef-components";
const todoHTML = /*html*/ `
<div>
<h2>Todo List</h2>
<form>
<input type="text">
<button type="submit" >Add</button>
</form>
<ul for-loop="index,item in items">
<li>
<span>{{item.text}}</span>
<button class="delete" index={{index}} >Delete</button>
</li>
</ul>
</div>
`;
Factory<{ items: { text: string }[] }>("wc-test", todoHTML, {
state: {
items: [],
},
eventListener: {
form: [
{
event: "submit",
handler(e) {
e.preventDefault();
const input = this.$("input") as HTMLInputElement;
this.state.items.unshift({ text: input.value });
input.value = "";
},
},
],
".delete": [
{
event: "click",
handler(ev) {
const idAttr = (ev.target as HTMLButtonElement).getAttribute("index");
const id = parseInt(idAttr ?? "");
this.state.items = this.state.items.filter((_, k) => k != id);
},
},
],
},
});import Factory, { globalStore } from "../src/index";
const userProfileHTML = /*html */ `
<style>
status-icon{
height:100%;
aspect-ratio: 1/1;
min-width: 20px;
display:inline-block
}
</style>
<div>
<div style="display: flex;align-items: center;justify-content:space-between;width: 30%;">
<h2>{{name}}</h2>
<!-- here we don't re-render status icon to preserve transition -->
<status-icon online={{online}}> </status-icon>
</div>
<p>Friends: {{user.friends}}</p>
<p>Best Friend : {{bestFriend}}</p>
<button class="update-status" re-render="online">{{online?'Disconnect':'Connect'}}</button>
</div>
`;
const statusIconHTML = /*svg */ `
<!-- here only the style needs to change -->
<style re-render='status'>
circle{
fill : {{status?'green':'red'}}
}
</style>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="50"
style="transition: fill 400ms ease-in"
/>
</svg>
`;
Factory("status-icon", statusIconHTML, {
noShadowRoot: true,
props: {
//we use the parser to retrieve a boolean
status: "json::online",
},
});
Factory("user-profile", userProfileHTML, {
state: {
online: false,
},
storeListener: {
name: "user.name", //you can subscribe to store nested property
user: "user", //or to the whole store and index it later
bestFriend: "user.friends.0", //array element can be accessed via index
},
eventListener: {
".update-status": [
{
event: "click",
handler() {
this.state.online = !this.state.online;
// we're updating the online prop 'manually' (which doesn't re-render)
this.$("status-icon")?.setAttribute("online", this.state.online);
},
},
],
},
});
globalStore.setState({
user: {
status: "Online",
name: "Jane",
friends: ["Marc", "John"],
},
});You can use this esbuild config:
// esbuild.config.mjs
import { build, context } from "esbuild";
const arg = process.argv.at(-1);
const config = {
entryPoints: ["src/index.ts"],
bundle: true,
outdir: "dist",
minify: true,
};
if (arg == "dev") {
const ctx = await context({
...config,
minify: false,
});
await ctx.watch();
} else {
await build({
...config,
});
}Usage:
node esbuild.config.mjs
# or dev mode :
node esbuild.config.mjs devIn VSCode you can edit your settings to have a shortcut toggling on and off the html mode:
// keybindings.json
[
...,
{
"key": "ctrl+shift+h", // Your preferred shortcut
"command": "workbench.action.editor.changeLanguageMode",
"args": "html",
"when": "editorLangId != 'html'" // Set HTML only when not already HTML
},
{
"key": "ctrl+shift+h", //Also here
"command": "workbench.action.editor.changeLanguageMode",
"args": "ts", //Set to your default language
"when": "editorLangId == 'html'"
}
]There is also this extension: es6-string-html. That highlights your string if you place a /*html*/ before.
Here are some patterns that I've found useful.
Declaring many event handles can be very verbose, I would suggest you define a helper method like:
const playHandle = (event: string, play: boolean) => ({
event,
handler(this: WebComponent<StateType>) {
this.state.playing = play;
... //event listener logic
},
});And then use it like this:
Factory<StateType>("hi-fi", html, {
...
eventListener: {
".container": [
openHandle("mouseenter", true),
openHandle("mouseleave", false),
],
".record": [
playHandle("mousedown", false),
playHandle("mouseup", true),
playHandle("touchstart", false),
playHandle("touchend", true),
],
...
}
})Which feels better to me.
Sometimes Frequently a well placed templated style tag might be smarter than relying on the elements style attribute.
For instance:
<div class="record">
<div style="{{open?'auto':'none'}}" class="id"></div>
<div class="picture"></div>
<div style="{{open?'auto':'none'}}" class="name"></div>
</div>Will be challenging to work with the more and more elements share this behavior.
Whereas this looks way better, and makes it easier to work with complex CSS:
<style>
.modal {
opacity : {
{
open?'1': "0";
}
}
}
</style>
<div class="record">
<div class="modal id"></div>
<div class="picture"></div>
<div class="modal name"></div>
</div>You don't have to put all your component's style inside a <style> tag. You can also import a stylesheet via a <link>, and still have a small <style> tag with your reactive CSS rules.
If you have enabled shadow root (the default), all the "light DOM" children of your component will be casted in any unnamed <slot> tag of your HTML.
You can use named slots as well, see Using templates and slots.
If you want to listen to children you'll need to set up a MutationObserver like so.
Factory("hifi-spectrum", graphHtml, {
....,
onMount() {
const callback = () => {
/*updating logic*/
};
const observer = new MutationObserver(callback);
observer.observe(this, { childList: true });
/*call the callback at least once for initialization*/
callback();
},
....
});If this comes up a lot, it could be added as an option.