vanjs-reactive-element
v0.5.0
Published
A reactive custom element base class for VanJS with any reactivity system
Downloads
17
Maintainers
Readme
Van Reactive Element
A lightweight web components library that seamlessly integrates VanJS with custom elements. Build reactive web components with minimal boilerplate and maximum flexibility.
Features
- Seamless VanJS Integration - Use VanJS state and reactivity within web components
- Two Paradigms - Choose between class or functional component styles inspired by Lit and Solid Element
- Built-in Styling - Scoped CSS support with shadow DOM encapsulation and adopted stylesheets
- Reactive by Design - Automatic UI updates when state changes
- Attribute Syncing - Automatic attribute to property conversion with type coercion
- Property Reflection - Optionally reflect property changes back to attributes
- Developer Friendly - TypeScript support, comprehensive lifecycle hooks
Installation
npm install vanjs-reactive-element vanjs-coreQuick Links
- Examples - Todo list from VanJS implemented in both class or functional component styles
- API Reference - Complete API documentation
- Reactivity Patterns - Learn about rendering patterns
- Attributes & Properties - Property configuration and type conversion
- Slots & Content - Content distribution with slots
Usage
Examples
Counter Example - Class Component
A simple counter component demonstrating basic state management:
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van });
class CounterElement extends VanReactiveElement {
static properties = {
count: { attribute: false, default: 0 }
};
static get styles() {
return css`
button {
margin: 0 5px;
}
`;
}
render() {
const { button, p } = van.tags;
return [
p('Count: ', this.count),
button({ onclick: () => this.count.val-- }, 'Decrement'),
button({ onclick: () => this.count.val++ }, 'Increment')
];
}
}
// Register the custom element
CounterElement.define();
// Use it
van.add(document.body, van.tags['counter-element']());Counter Example - Functional Component
The same counter using the functional approach:
const CounterElement = define(
'counter-element',
{
properties: {
count: 0
},
styles: css`
button {
margin: 0 5px;
}
`
},
(element, { noShadowDOM, onCleanup, onMount }) => {
return () => {
const { button, p } = van.tags;
return [
p('Count: ', element.count),
button({ onclick: () => element.count.val-- }, 'Decrement'),
button({ onclick: () => element.count.val++ }, 'Increment')
];
};
}
);
// Use it
van.add(document.body, van.tags['counter-element']());Todo List Example - Class Component
A more complex example implementing todo functionality from VanJS:
// Extract VanReactiveElement and css helper from vanRE
const { VanReactiveElement, css, define } = vanRE({ van });
// Define a custom element <todo-list> by extending VanReactiveElement
class TodoList extends VanReactiveElement {
// Props with default values and types
static properties = {
title: { default: 'Todo List', type: String },
titleAttributePostfix: 'Will be replaced',
titlePropertyPostfix: { attribute: false, default: 'Will also be replaced' }
};
// Define component styles
static get styles() {
return css`
a {
cursor: pointer;
}
`;
}
// Factory method to create a reactive todo item
createTodoItem(text) {
const done = van.state(false); // Track if the todo is done
const deleted = van.state(false); // Track if the todo is deleted
const { div, input, span, del, a } = van.tags;
// Return a function that reactively renders the todo item
return () =>
deleted.val
? null // If deleted, render nothing
: div(
{ class: 'todo-item' },
input({
type: 'checkbox',
checked: done,
onclick: (e) => (done.val = e.target.checked) // Toggle done state
}),
() => (done.val ? del : span)(text), // Strike-through if done
a({ onclick: () => (deleted.val = true) }, '❌') // Delete button
);
}
// Render method for the main todo list UI
render() {
const { div, h2, input, button } = van.tags;
const inputDom = input({ type: 'text' }); // Input for new todo text
const count = van.state(0); // Track number of add clicks
const derived = van.derive(() => count.val * 2); // Derived state
// Main DOM structure
const dom = div(
h2(this.title, ' - Attribute: ', this.titleAttributePostfix, ' - Property: ', this.titlePropertyPostfix),
div('Add Click Count: ', count, ' * 2 = ', derived),
inputDom,
button(
{
onclick: () => (
count.val++, // Increment count
van.add(dom, this.createTodoItem(inputDom.value)) // Add new todo item
)
},
'Add'
)
);
return dom;
}
}
// Register the custom element as <todo-list>
TodoList.define();
// State for the property postfix, updates every second
const titlePropertyPostfix = van.state(new Date().toLocaleTimeString());
// Create the todo-list element and set its attribute postfix
const todoList = van.tags['todo-list']({
'title-attribute-postfix': 'Attribute Postfix',
titlePropertyPostfix
});
// Update postfixes every second
const intervalId = setInterval(() => {
const timeString = new Date().toLocaleTimeString();
titlePropertyPostfix.val = timeString; // Update property postfix
todoList.setAttribute('title-attribute-postfix', timeString); // Update attribute postfix
}, 1000);
// Mount the todo list to the document body
van.add(document.body, todoList);Tip: You can always use the native
customElements.definemethod to register your component classes if you prefer. The provideddefinemethod is a convenience, but not required.
Todo List Example - Functional Component
// Define a custom element "todo-list"
const TodoList = define(
'todo-list',
{
// Attributes (reactive, read-only, settable via setProperty/setProperties)
attributes: {
title: { type: String, default: 'Todo List' },
titleAttributePostfix: { type: String, default: 'Will be replaced' }
},
// Properties (reactive, read-write directly, settable via setProperty/setProperties)
properties: {
titlePropertyPostfix: 'Will also be replaced'
},
// Component styles
styles: css`
a {
cursor: pointer;
}
`
},
(element, { noShadowDOM, onCleanup, onMount }) => {
// Factory for creating a todo item component
const createTodoItem = (text) => {
const done = van.state(false); // Track if todo is done
const deleted = van.state(false); // Track if todo is deleted
const { div, input, span, del, a } = van.tags;
// Return a function that renders the todo item reactively
return () =>
deleted.val
? null // If deleted, render nothing
: div(
{ class: 'todo-item' },
input({
type: 'checkbox',
checked: done,
onclick: (e) => (done.val = e.target.checked)
}),
() => (done.val ? del : span)(text), // Strike-through if done
a({ onclick: () => (deleted.val = true) }, '❌') // Delete button
);
};
// Return the render function for the todo list
return () => {
const { div, h2, input, button } = van.tags;
const inputDom = input({ type: 'text' }); // Input for new todos
const count = van.state(0); // Count of add clicks
const derived = van.derive(() => count.val * 2); // Derived state
// Main DOM structure
const dom = div(
h2(element.title, ' - Attribute: ', element.titleAttributePostfix, ' - Property: ', element.titlePropertyPostfix),
div('Add Click Count: ', count, ' * 2 = ', derived),
inputDom,
button(
{
onclick: () => (
count.val++, // Increment count
van.add(dom, createTodoItem(inputDom.value)) // Add new todo item
)
},
'Add'
)
);
return dom;
};
}
);
// Note: The `define` function returns a custom element class, which can be subclassed or registered manually if needed.
// State for the property postfix, updates every second
const titlePropertyPostfix = van.state(new Date().toLocaleTimeString());
// Create the todo-list element, set attribute postfix
const todoList = van.tags['todo-list']({
'title-attribute-postfix': 'Attribute Postfix',
titlePropertyPostfix
});
// Update postfixes every second
const intervalId = setInterval(() => {
const timeString = new Date().toLocaleTimeString();
titlePropertyPostfix.val = timeString; // Update property postfix
todoList.setAttribute('title-attribute-postfix', timeString); // Update attribute postfix
}, 1000);
// Mount the todo list to the document body
van.add(document.body, todoList);Note: When using the define function, you must provide the full tag name (including a hyphen) as required by the web components spec.
API
vanRE(options)
Initialize the VanJS Reactive Element library.
Parameters:
options.rxScope- Optional, reactive scope function for managing component lifecycle (typicallyeffectScopefrom your reactivity library)options.van- Required, VanJS instance with requiredaddandstatemethods
Returns:
VanReactiveElement- Base class for creating class componentscss- Tagged template literal for component stylesdefine- Function for creating functional components
VanReactiveElement Class
Base class for creating web components with VanJS integration.
Static Properties
properties- Component property definitions with configuration:attribute- Enable attribute binding (true by default) or specify custom attribute nameconverter- Custom converter withfromAttributeandtoAttributemethodsdefault- Default property valuereflect- Reflect property changes back to attributestype- Property type (String, Number, Boolean, Object, Array) for automatic conversion
shadowRootOptions- Shadow root creation options (default:{ mode: 'open' })styles- Component CSS styles (scoped to shadow DOM). Usecsstagged template literal or CSSStyleSheet for adopted stylesheets
Instance Methods
createRenderRoot()- Create the render root (override to returnthisfor light DOM)dispatchCustomEvent(name, options)- Dispatch a custom eventhasShadowDOM()- Check if the component uses shadow DOMonCleanup()- Called when the component is disconnected from DOMonMount()- Called after the component connects to DOMquery(selector)- Query single element within render rootqueryAll(selector)- Query all elements within render rootregisterDisposer(fn)- Register a cleanup functionrender()- Define the component's content. Returns either:- Content directly with state objects for automatic reactivity:
div(this.myState) - A function returning content for computed values or when using
.val:() => display.val ? div(this.myState) : ''
- Content directly with state objects for automatic reactivity:
setProperty(name, value)- Set a single property value (works for both attributes and properties)setProperties(properties)- Set multiple properties at once using an object (works for both attributes and properties)
Static Methods
define(name?)- Register the component as a custom element
Defines a custom element using the provided name. The name must include a hyphen (per custom elements spec). If nonameis provided, the class name of the component will be used to generate the custom element name by converting from PascalCase/camelCase to kebab-case.
define(customElementName, options, setup)
Create a functional component.
Parameters:
customElementName- Custom element name (must include a hyphen)options- Component configuration object:attributes- Attribute property definitions (become StateView - read-only)properties- Internal property definitions (become State - read-write)styles- Component styles (usecsstagged template literal or CSSStyleSheet)shadowRootOptions- Shadow root configuration (default:{ mode: 'open' })
setup- Setup function called once per instance that returns the render function
Returns:
A custom element class, which can be subclassed or registered manually if needed.
Setup Function Parameters:
element- The custom element instance with typed reactive properties:- Attributes (read-only):
element.attributeNameis a StateView- Reading:
element.attributeName.valor direct bindingelement.attributeNamein templates - Setting: Use
element.setProperty('attributeName', 'new value')andelement.setProperties(properties)
- Reading:
- Properties (read-write):
element.propertyNameis a State- Reading:
element.propertyName.valor direct bindingelement.propertyNamein templates - Setting:
element.propertyName.val = { new: 'value' }to update the State value orelement.setProperty('attributeName', 'new value')andelement.setProperties(properties)to update the State reference or value
- Reading:
- Universal setters:
element.setProperty('name', value)andelement.setProperties(properties)work for both attributes and properties
- Attributes (read-only):
context- Component context object:noShadowDOM()- Disable shadow DOMonCleanup(fn)- Set cleanup callbackonMount(fn)- Set mount callback
Setup Function Returns: The setup function must return a render function that defines the component's content, or nothing (void) if no rendering is needed.
Important Notes
- Reactivity: Properties are not reactive by default. Use
van.state()orvan.derive()for reactive properties. - Element Naming: You must provide the full custom element name (with hyphen) when calling
define. - Property Binding: Properties with attribute binding use
van.stateinternally for reactivity. - Element-based API in Functional Components:
- Attributes are read-only (
StateView) - useelement.setProperty('attrName', value)andelement.setProperties(properties)to update - Internal properties are read-write (
State) - can be updated directly withelement.propName.val = value
- Attributes are read-only (
Example: Element API Usage
define(
'my-component',
{
attributes: {
// These become StateView (read-only)
name: { type: String, default: 'World' },
count: { type: Number, default: 0 }
},
properties: {
// These become State (read-write)
data: { foo: 'bar' },
items: []
}
},
(element) => {
// Reading attributes (StateView)
console.log(element.name.val); // "World"
// Setting attributes via setProperties method
element.setProperties({ count: 42, name: 'Hello' });
// Reading/writing properties
element.data.val = { foo: 'updated' }; // ✅ Updates value properly
element.items.val.push('new item'); // ✅ Modifying array contents
// Using setProperty method (works for both attributes and properties)
element.setProperty('data', { foo: 'via setter' }); // ✅ Type-safe value update
// Return render function
return () => div('Name: ', element.name, ' Count: ', element.count, ' Data: ', () => JSON.stringify(element.data.val));
}
);Reactivity Patterns
VanJS Reactive Element supports two patterns for reactive rendering:
Direct State Binding (Recommended for simple cases):
render() { const {div} = van.tags; // Pass derive/state objects directly - VanJS handles reactivity return div('Count: ', this.count); }Function-based Rendering (Required for computed values or
.valaccess):render() { const {div} = van.tags; // Return a function when using computed values or .val return () => div( div('Count: ', this.count), div('Doubled: ', () => this.count.val * 2) ); }
Use direct derive/state binding when possible for cleaner code. Use function-based rendering when you need to access .val use computed properties that aren't van.derive states.
Example: When to Use Each Pattern
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class MyComponent extends VanReactiveElement {
// State
count = van.state(0);
multiplier = van.state(2);
// Computed getter (not a van.derive state)
get doubled() {
return this.count.val * 2;
}
render() {
const { div } = van.tags;
// ❌ Won't be reactive - getter is called once
// return div('Doubled: ', this.doubled);
// ✅ Use van.derive state or function wrapper for computed values
return div('Doubled: ', () => this.doubled);
}
}Adopted Stylesheets
VanJS Reactive Element supports modern CSS features like adopted stylesheets for better performance:
class MyComponent extends VanReactiveElement {
// Create a reusable stylesheet
static stylesheet = new CSSStyleSheet();
static {
// Populate the stylesheet
this.stylesheet.replaceSync(`
:host {
display: block;
padding: 20px;
}
.title {
color: #333;
font-size: 24px;
}
`);
}
// Return the stylesheet instead of a string
static get styles() {
return this.stylesheet;
}
render() {
const { h1 } = van.tags;
return h1({ class: 'title' }, 'Hello World');
}
}Adopted stylesheets are more efficient for components that are used multiple times, as the browser can share the same stylesheet across all instances.
Attributes and Properties
VanJS Reactive Element provides automatic attribute to property syncing with type conversion:
Property Configuration
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class MyElement extends VanReactiveElement {
static properties = {
// Simple property with default value
name: { default: 'Anonymous' },
// Property with type converter
count: {
type: Number,
default: 0
},
// Property with custom attribute name
isActive: {
type: Boolean,
default: false,
attribute: 'active' // Maps to 'active' attribute instead of 'is-active'
},
// Property that reflects changes back to attribute
status: {
type: String,
default: 'pending',
reflect: true
},
// Property with custom converter
data: {
type: Object,
converter: {
fromAttribute: (value) => (value ? JSON.parse(value) : null),
toAttribute: (value) => (value ? JSON.stringify(value) : null)
}
},
// Property without attribute binding
internal: {
default: null,
attribute: false
}
};
}Built-in Type Converters
- String: Pass-through (default)
- Number: Converts to/from numeric values
- Boolean: Presence = true, absence = false
- Object/Array: JSON serialization
Usage Example
// HTML
<my-element name="John Doe" count="42" active data='{"role": "admin"}'></my-element>;
// JavaScript
const element = document.querySelector('my-element');
// Properties are automatically synced from attributes
console.log(element.name); // "John Doe"
console.log(element.count); // 42 (number)
console.log(element.isActive); // true (boolean)
console.log(element.data); // { role: "admin" } (object)
// Update properties
element.count = 100;
element.status = 'completed';
// Reflected properties update attributes
console.log(element.getAttribute('status')); // "completed"Functional Component Attributes
const ToggleButton = define(
'toggle-button',
{
attributes: {
pressed: {
type: Boolean,
default: false,
reflect: true,
attribute: 'aria-pressed' // Property with custom ARIA attribute name
},
label: {
type: String,
default: 'Toggle'
}
}
},
(element, {}) => {
return () => {
const { button } = van.tags;
return button(
{
'aria-pressed': element.pressed,
onclick: () => element.setProperty('pressed', !element.pressed.val)
},
element.label
);
};
}
);Slots and Content Distribution
VanJS Reactive Element fully supports Web Components slots for content distribution.
Note: Use native slot APIs such as slot.assignedNodes() and slot.assignedElements() for slot management:
Shadow DOM Slots
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class CardComponent extends VanReactiveElement {
static get styles() {
return css`
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.header {
background: #f5f5f5;
padding: 16px;
font-weight: bold;
}
.body {
padding: 16px;
}
.footer {
padding: 16px;
border-top: 1px solid #eee;
}
`;
}
render() {
const { div, slot } = van.tags;
return div(
{ class: 'card' },
div({ class: 'header' }, slot({ name: 'header' }, 'Default Header')),
div({ class: 'body' }, slot('Default Content')),
div({ class: 'footer' }, slot({ name: 'footer' }))
);
}
}
// Usage
<card-component>
<h2 slot="header">Card Title</h2>
<p>This is the card content</p>
<p>Multiple elements can go in the default slot</p>
<button slot="footer">Action</button>
</card-component>;Working with Slots
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class SlotAwareComponent extends VanReactiveElement {
onMount() {
// Access slots directly using standard Web Component APIs
const slots = this.renderRoot.querySelectorAll('slot');
slots.forEach((slot) => {
// Get assigned nodes for each slot
const assignedNodes = slot.assignedNodes();
const assignedElements = slot.assignedElements();
// Listen for slot changes
slot.addEventListener('slotchange', (e) => {
const newNodes = e.target.assignedNodes();
console.log(`Slot "${slot.name || 'default'}" changed:`, newNodes);
});
});
// Check specific slot content
const footerSlot = this.renderRoot.querySelector('slot[name="footer"]');
if (footerSlot && footerSlot.assignedNodes().length === 0) {
console.log('No footer content provided');
}
}
}Light DOM Content Organization
For components using light DOM (no shadow DOM), override createRenderRoot() to return this:
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class LightDomLayout extends VanReactiveElement {
createRenderRoot() {
return this; // Use light DOM
}
render() {
const { div, header, main, footer } = van.tags;
return div({ class: 'layout' }, header({ class: 'header-area' }), main({ class: 'content-area' }), footer({ class: 'footer-area' }));
}
onMount() {
// Organize children by slot attribute
Array.from(this.children).forEach((child) => {
const slot = child.getAttribute('slot');
if (slot === 'header') {
this.querySelector('.header-area')?.appendChild(child);
} else if (slot === 'footer') {
this.querySelector('.footer-area')?.appendChild(child);
} else {
this.querySelector('.content-area')?.appendChild(child);
}
});
}
}Conditional Slot Rendering
import { effectScope } from 'your-reactivity-library';
import van from 'vanjs-core';
import vanRE from 'vanjs-reactive-element';
const { VanReactiveElement, css } = vanRE({ van, rxScope: effectScope });
class ConditionalSlots extends VanReactiveElement {
hasIcon = van.state(false);
render() {
const { div, span, slot } = van.tags;
return () =>
div(
{ class: 'button-wrapper' },
this.hasIcon.val && span({ class: 'icon' }, slot({ name: 'icon' })),
span({ class: 'label' }, slot('Button'))
);
}
onMount() {
// Check if icon slot has content
const iconSlot = this.renderRoot.querySelector('slot[name="icon"]');
if (iconSlot) {
this.hasIcon.val = iconSlot.assignedNodes().length > 0;
}
}
}License
MIT License
