zef-components
v2.0.7
Published
A lightweight library for creating reactive web components with state management, declarative templates, and seamless store integration.
Readme
Zef-components
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 shack in the middle of the forest - or "a localized AlpineJS" that won't fully parse your html.
Features
- 🔧 Declarative Templates - Use Mustache-like syntax for dynamic content
- ⚡ Reactive State - Re-render parts of HTML when state changes
- 🔄 State Management - Global Zustand-powered store integration with Immer
- 🎯 Event Handling - Declarative event listener setup
- 🔁 Loop and Conditional Rendering - Built-in
for-loopandifdirectives - 🎨 Shadow DOM - Optional shadow DOM support for style encapsulation
- 🏷️ TypeScript - Fully typed with comprehensive type definitions and autocomplete
- 📦 Bind external objects - Bind data source from attributes or global store to your component.
- 🧩 JSON Support - Serialize/deserialize objects in templates and attributes
Installation
npm install zef-componentsDependencies
Zef-components depends on:
- Zustand (^5.0.8) for state management
- Immer (^10.1.3) for immutable state updates
Usage Guide
Basic Component
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++;
},
},
],
},
});You can use all the HTML tags inside your components, including <link> <style> and <svg> !
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>
<body>
<my-counter></my-counter>
....
</body>
</html>Template Syntax
Variable Interpolation
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()
JSON Serialization
Serialize objects in templates using the json:: prefix:
<div>
<p>User data: {{json::user}}</p>
<p>Config: {{json::config}}</p>
</div>Ternary Expressions
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
Re-render Directive
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
If Directive
<div if="open">
<p>Show content if `open` is true</p>
</div>The content of the div will only be rendered if open is evaluated to be true.
The container will still be present in your html no matter what, but will inherit from a display:none style attribute if the condition is false.
Loop Directive
Generate HTML through iteration:
<div for-loop="item in collection">{{item.name}}</div>
<div for-loop="id,item of collection">{{item.name}}</div>Supported collection types:
- Numbers: iterates from 0 to collection-1
- Arrays: iterates through each item
Elements of you colletion will be alliased as the name you provided (here item), you can also access indexes.
Note: The content of the div will be cloned, you do not need to add any re-render directive as the library handles that for you.
A for-loop block will be fully re-rendered everytimes the 'array' changes.
Reactive Props
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*/The props will be added to this.state as a function that return its current value.
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;Query Selection
Use these methods to query elements within your component:
this.$(css)- Alias forquerySelectorthis.$$(css)- Alias forquerySelectorAll
These methods are available in all component methods including lifecycle hooks and event handlers.
Lifecycle Callbacks
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
});Input Value Interface
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.
Shadow DOM and Styling
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
});When using Shadow DOM:
- Styles are encapsulated within the component
- Use
<slot>elements to project light DOM content see Using templates and slots. - Set
noShadowRoot: truefor small reactive HTML blocks
Event Handling
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 the vanilla addEventListener will be lost, please use these method to handle event inside the component root.
Global Store Integration
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
});
// Programmatic subscription
const id = this.subStore("localUser", (s) => s.user);
// Programmatic unsubscription
this.unsubStore(id);
},
});The store item will be added to this.state as a function that returns it's current value.
API Reference
Factory Options (FactoryOption<T, K, L>)
| 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<T, K, L> | Event listener configuration |
| storeListener | K | Store binding configuration |
| noShadowRoot | boolean | Disable shadow DOM encapsulation |
Component Instance (WebComponent<T, K, L>)
This object inherits from all the methods of HTMLElement, including remove(), getAttribute()
| Property/Method | Type Signature | Description |
| ---------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------ |
| state | T & Record<keyof K, () => any> & Record<keyof L, () => string \| object> | Reactive state object |
| value | any | Component's current value |
| root | HTMLElement \| ShadowRoot | Root element (shadow root or element itself) |
| getStore() | () => GlobalStore | Returns current global store state |
| setStore(updater) | (updater: (store: GlobalStore) => any) => void | Updates store using Immer producer function |
| subStore(key, subscriber) | (key: string, subscriber: (store: GlobalStore) => any) => number | Subscribe to store changes |
| unsubStore(index) | (index: number) => void | Unsubscribe from store changes |
| $(css) | (css: string) => Element \| null | Query single element |
| $$(css) | (css: string) => NodeListOf<Element> | Query multiple elements |
| $$on(css, eventHandle) | (css: string, ev: EventHandle<T, K, L>) => void | Register event listener |
| $$off(css, event) | (css: string, event: string) => void | Remove event listener |
| reRenderProperty(pathname) | (pathname: string) => void | Manually trigger re-render for specific property |
Developer Experience
Building Components
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 devRecipes
Inter-components communication
The library gives you 3 solution to exchange data between components :
- Passing data through attribute/props :
Parent component can pass data as attribute to it's child, however note that the following:
<child-component data="{{json:data}}" re-render="{{data}}"></child-component>Will fully re-render the component each time the data object change.
To prevent this, remove the re-render directive (the component will then be initialized with data={{json:data}} but not re-render on change) and interact with the child using :
//in a event listerner or a lifecycle hook of your factory option
this.$("child-component").setAttribute("data", marshallJSON(this.state.data));The child component can the listen to the data props using :
Factory("child-component",html, {
...,
props : {
data : "json:data" //parse data from a json attribute
}
})- Input-like interaction
If you only need to get data from the child to the parent you can mobilize the input like beahavior of components by binding the component's value to it's internal state.
Factory("child-component",html, {
state : {
name : "John"
}
value(){
return {userName : "John"}
}
})Every state change that'll modify value will also dispatch an input event on the component that its parent can listen to.
Factory("parent-component",html, {
...,
eventListener : {
"child-component" : [
{
event : "input",
handler(ev){
const {userName} = (ev.target as HTMLInputElement).value
this.state.userName = userName
}
}
]
}
})- Global store
The less subtile but most powerfull methods is to use the global store to exchange data no matter they relative hierarchy. You can have several components depending on the same store items but be carefull as this can lead to funky behavior if not handled properly.
Working with children
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();
},
....
});Also know that when using shadow root :
this.$andthis.$$will query the shadow DOM of the component (meaning the html you provided)this.querySelectorandthis.querySelectorAllwill query the light DOM of the component (which includes childs).this.querySelectorAll([slot=header])will get all childs placed in theheadernamed slot.
Editor Support
Syntax Highlighting
In VSCode, you can edit your settings to have a shortcut toggling on and off the HTML mode:
// keybindings.json
[
{
"key": "ctrl+shift+h",
"command": "workbench.action.editor.changeLanguageMode",
"args": "html",
"when": "editorLangId != 'html'"
},
{
"key": "ctrl+shift+h",
"command": "workbench.action.editor.changeLanguageMode",
"args": "ts",
"when": "editorLangId == 'html'"
}
]Alternatively, use the es6-string-html extension which highlights HTML strings when prefixed with /*html*/.
TypeScript Support
All components are fully typed. For optimal TypeScript support:
- Define interfaces for your component state
- Use generics when calling the Factory function
- Type your event handlers and lifecycle methods
Example:
interface MyState {
count: number;
items: string[];
}
const MyComponent = Factory<MyState>("my-component", html, {
state: { count: 0, items: [] },
props: { index: "index" },
onMount() {
// TypeScript knows this.state is contains props and store variables
this.state.count; //(property) count: number
this.state.index; //(property) index: () => string | object
},
});Performance Tips
- Be specific with
re-renderdirectives to minimize DOM updates - For complex components, consider breaking them into smaller sub-components
- Use store listeners only for data that actually needs to be reactive
- Avoid deep nesting in state objects for better performance
