latchjs
v0.2.2
Published
A pull-based reactive system with lazy evaluation, automatic memory management, and declarative DOM bindings
Maintainers
Readme
latchjs
A pull-based reactive system with automatic memory management
Quick Start • API Reference • Examples
Features
- Pull-Based Reactivity: Values recompute only when read, not when dependencies change
- Automatic Memory Management: Uses
WeakRef+FinalizationRegistryfor cleanup without manual dispose - AsyncIterator Streams: Consume reactive changes with
for await...ofand natural backpressure - No Build Step Required: Works directly in browsers via ES modules or IIFE
- Declarative DOM Bindings:
@click,:class,@for,@ifdirectives work at runtime - Component System: Props, slots, events, scoped styles, and lifecycle hooks
Install
npm install latchjsOr use via CDN:
<script src="https://unpkg.com/latchjs/dist/latch.iife.min.js"></script>
<script>
const { reactive, mount } = Latch;
</script>Or ES modules:
<script type="module">
import { reactive, mount } from 'https://unpkg.com/latchjs/dist/latch.esm.min.js';
</script>Quick Start
Declarative Templates
<div id="app">
<h1>Hello {{ name }}!</h1>
<input r-model="name" type="text">
<p>Count: {{ count }}</p>
<button @click="count++">Increment</button>
<div @if="count > 10">That's a lot!</div>
<div @else-if="count > 0">Keep going...</div>
<div @else>Click to start</div>
<ul>
<template @for="item in items">
<li>{{ item.name }}</li>
</template>
</ul>
</div>
<script type="module">
import { reactive, mount } from 'latchjs';
mount('#app', reactive({
name: 'World',
count: 0,
items: [{ name: 'Apple' }, { name: 'Banana' }]
}));
</script>Programmatic API
import { reactive, computed, effect, bind } from 'latchjs';
const state = reactive({ price: 10, quantity: 2 });
const total = computed(() => state.price * state.quantity);
effect(() => {
console.log(`Total: $${total.value}`);
});
bind(document.getElementById('output'), {
text: () => `$${total.value}`,
class: { expensive: () => total.value > 50 },
});
state.quantity = 5; // Effect logs: "Total: $50"Custom Elements
import { ReactiveElement } from 'latchjs';
class ProductCard extends ReactiveElement {
static reactive = { quantity: 1 };
static template = `
<div class="card">
<span class="qty">1</span>
<button class="add">+</button>
</div>
`;
static bindings = {
'.qty': { text: s => s.quantity },
'.add': { on: { click: s => s.quantity++ } }
};
}
customElements.define('product-card', ProductCard);Component System
import { defineComponent, mountAllComponents } from 'latchjs';
defineComponent({
name: 'user-card',
props: {
name: { type: 'string', required: true },
role: { type: 'string', default: 'Member' }
},
template: `
<div class="card">
<h3 r-text="name"></h3>
<span r-text="role"></span>
<slot></slot>
</div>
`,
styles: `.card { padding: 1rem; border: 1px solid #ccc; }`,
setup(props) {
return { name: props.name, role: props.role };
}
});
// <user-card name="Alice" role="Admin">Content</user-card>
mountAllComponents(document.body);API Reference
Reactive Primitives
reactive(object)
Creates a deeply reactive proxy.
const state = reactive({ user: { name: 'Alice' }, items: [] });
state.user.name = 'Bob';
state.items.push({ id: 1 });computed(fn)
Lazy computed value. Only recomputes when read after dependencies change.
const total = computed(() => state.items.reduce((s, i) => s + i.price, 0));
console.log(total.value);effect(fn)
Runs side effects when dependencies change. Returns stop function.
const stop = effect((onCleanup) => {
const timer = setInterval(() => console.log(state.count), 1000);
onCleanup(() => clearInterval(timer));
});
stop();watch(source, callback, options?)
Watch specific values with old/new comparison.
watch(
() => state.user.name,
(newVal, oldVal) => console.log(`${oldVal} → ${newVal}`),
{ immediate: true }
);iterate(source)
Consume changes as AsyncIterator with backpressure.
for await (const value of iterate(() => state.query)) {
await processValue(value);
}ref(value)
Simple reactive reference.
const count = ref(0);
count.value++;readonly(object) / shallowReactive(object)
const readonlyState = readonly(state);
const shallow = shallowReactive({ a: { b: 1 } });Collections
reactiveMap() / reactiveSet()
const map = reactiveMap(new Map([['a', 1]]));
const set = reactiveSet(new Set([1, 2, 3]));
effect(() => console.log('Map size:', map.size));
map.set('b', 2);Batching
batch(fn) / flushSync()
batch(() => {
state.a = 1;
state.b = 2;
});
flushSync();DOM Bindings
Directives
| Directive | Shorthand | Description |
|-----------|-----------|-------------|
| {{ expr }} | | Text interpolation |
| r-text="expr" | | Set text content |
| r-html="expr" | | Set innerHTML (sanitized) |
| r-show="expr" | | Toggle visibility |
| r-if="expr" | @if | Conditional render |
| r-else-if="expr" | @else-if | Chained condition |
| r-else | @else | Else branch |
| r-for="item in items" | @for | List rendering |
| r-model="prop" | | Two-way binding |
| r-class:name="expr" | .name | Toggle class |
| r-style:prop="expr" | | Set style property |
| r-attr:name="expr" | :name | Set attribute |
| r-on:event="handler" | @event | Event listener |
| r-transition="name" | | CSS transitions |
Event Modifiers
<form @submit.prevent="save()">
<button @click.stop="handle()">
<button @click.once="init()">
<input @keydown.enter="submit()">
<input @keydown.esc="cancel()">Modifiers: .prevent, .stop, .once, .capture, .passive, .enter, .esc, .tab, .space, .up, .down, .left, .right, .delete
Transitions
<div @if="visible" r-transition="fade">Content</div>
<style>
.fade-enter-from, .fade-leave-to { opacity: 0; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
</style>bind(element, config)
bind(element, {
text: () => state.label,
html: () => state.richContent,
show: () => !state.hidden,
class: { active: () => state.isActive },
style: { opacity: () => state.isVisible ? '1' : '0' },
attr: { disabled: () => state.loading ? '' : null },
on: { click: () => state.count++ }
});mount(selector, state)
mount('#app', reactive({ count: 0 }));Components
defineComponent(options)
defineComponent({
name: 'my-button',
props: {
label: { type: 'string', required: true },
variant: { type: 'string', default: 'primary' }
},
emits: ['click'],
template: `<button :class="variant" @click="handleClick" r-text="label"></button>`,
styles: `.primary { background: blue; }`,
setup(props, { emit }) {
return {
label: props.label,
variant: props.variant,
handleClick: () => emit('click')
};
}
});| Function | Description |
|----------|-------------|
| defineComponent(options) | Register component |
| createComponent(name, props?) | Create instance |
| mountComponent(el, name, props?) | Mount to element |
| mountAllComponents(root?) | Auto-mount all |
| getComponent(name) | Get definition |
| hasComponent(name) | Check exists |
| listComponents() | List all |
| unregisterComponent(name) | Remove |
Built-in Components
<latch-teleport to="#modals">...</latch-teleport>
<latch-fragment>...</latch-fragment>
<latch-dynamic :is="componentName" :props="props"></latch-dynamic>Security
import { sanitizeHTML, configureSanitizer } from 'latchjs';
const safe = sanitizeHTML('<script>alert(1)</script><p>Hello</p>');
// '<p>Hello</p>'
configureSanitizer({
allowedTags: ['p', 'a', 'strong'],
allowDataUrls: false
});DevTools
import { configureWarnings, showDebugPanel, printSummary } from 'latchjs';
configureWarnings({ level: 'warn', throwOnError: false });
showDebugPanel();
printSummary();Utilities
| Function | Description |
|----------|-------------|
| toRaw(proxy) | Get original object |
| isReactive(value) | Check if reactive |
| isComputed(value) | Check if computed |
| isRef(value) | Check if ref |
| isReadonly(value) | Check if readonly |
| cleanupElement(el) | Manual cleanup |
Examples
| Example | Description | |---------|-------------| | counter.html | Counter implementations | | todo.html | Task manager | | search.html | Real-time search | | dashboard.html | Analytics dashboard | | form.html | Form validation | | ecommerce.html | Shopping cart | | async-stream.html | AsyncIterator demo | | components.html | Component system | | devtools.html | DevTools panel |
npx serve examplesTypeScript
import { reactive, computed, ref, Ref, ComputedRef } from 'latchjs';
interface State {
user: { name: string; age: number };
items: string[];
}
const state = reactive<State>({
user: { name: 'Alice', age: 30 },
items: []
});
const count: Ref<number> = ref(0);
const doubled: ComputedRef<number> = computed(() => count.value * 2);Browser Support
Requires: Proxy, WeakRef, FinalizationRegistry, queueMicrotask
Chrome 84+, Firefox 79+, Safari 14.1+, Edge 84+
Development
npm install
npm run build
npm test # 354 tests
npm run dev
npm run typecheckLicense
MIT
