Package Exports
- latchjs
Readme
latchjs
A pull-based reactive system with automatic memory management
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