Package Exports
- latchjs
Readme
latch
A pull-based reactive system with lazy evaluation, automatic memory management, and declarative DOM bindings.
Computations only run when values are read, not when dependencies change. This means:
- No wasted computation for unobserved values
- Natural backpressure via AsyncIterators
- Zero memory leaks by design (WeakRef + FinalizationRegistry)
- No build step required for consumers
Install
npm install latchjsOr use via CDN:
<script src="https://unpkg.com/latch/dist/latch.iife.js"></script>
<script>
const { reactive, mount } = Latch;
</script>Quick Start
Attribute Bindings (simplest — no JS binding code)
<div id="app">
<h1>Hello {{ name }}!</h1>
<input r-model="name" type="text">
<p>Count: {{ count }}</p>
<button @click="count++">+1</button>
<!-- Conditional rendering with @if/@else -->
<div @if="count > 10">Way too many!</div>
<div @else-if="count > 5" .highlight="true">Getting high</div>
<div @else-if="count > 0">Count is positive</div>
<div @else>Click the button!</div>
<!-- List rendering -->
<template @for="item in items">
<div .done="item.done">{{ item.text }}</div>
</template>
</div>
<script type="module">
import { reactive, mount } from 'latchjs';
const state = reactive({
name: 'World',
count: 0,
items: [
{ text: 'Learn latch', done: true },
{ text: 'Build something', done: false }
]
});
mount('#app', state);
</script>Programmatic API
import { reactive, computed, effect, bind } from 'latchjs';
const state = reactive({ count: 0, name: 'World' });
// Lazy computed — only evaluates when .value is read
const double = computed(() => state.count * 2);
// Effect — re-runs when tracked dependencies change
effect(() => {
console.log(`Count: ${state.count}, Double: ${double.value}`);
});
// DOM binding
bind(document.getElementById('output'), {
text: () => `${state.name}: ${state.count}`,
class: { active: () => state.count > 0 },
show: () => state.count < 100,
});
state.count++; // Effect re-runs in next microtaskCustom Elements
import { ReactiveElement } from 'latchjs';
class MyCounter extends ReactiveElement {
static reactive = { count: 0 };
static template = `
<span class="count"></span>
<button class="btn">+1</button>
`;
static bindings = {
'.count': { text: (s) => s.count },
'.btn': { on: { click: (s) => s.count++ } },
};
}
customElements.define('my-counter', MyCounter);<my-counter></my-counter>API Reference
Core Primitives
reactive(object)
Wraps an object in a reactive Proxy. Nested objects are wrapped lazily on access.
const state = reactive({ user: { name: 'Alice' }, items: [] });
state.user.name = 'Bob'; // Deep reactivitycomputed(fn)
Creates a lazily-evaluated derived value. Only recomputes when read after dependencies change.
const total = computed(() => state.price * state.quantity);
console.log(total.value); // Computes here, not beforeSupports generator functions for multi-step computations:
import { step } from 'latchjs';
const invoice = computed(function*() {
const subtotal = yield* step(() => state.items.reduce((s, i) => s + i.price, 0));
const tax = yield* step(() => subtotal * state.taxRate);
return { subtotal, tax, total: subtotal + tax };
});effect(fn)
Runs a side effect that auto-tracks dependencies. Returns a stop function.
const stop = effect((onCleanup) => {
const timer = setInterval(() => console.log(state.count), 1000);
onCleanup(() => clearInterval(timer));
});
stop(); // Permanently stops the effectwatch(source, callback, options?)
Watches a reactive source and calls back with old/new values.
watch(
() => state.user.name,
(newName, oldName) => console.log(`${oldName} → ${newName}`),
{ immediate: true }
);iterate(source)
Consume reactive changes as an AsyncIterator with natural backpressure.
for await (const query of iterate(() => state.searchQuery)) {
const results = await fetch(`/api/search?q=${query}`);
// Next iteration waits until this completes
renderResults(await results.json());
}DOM Bindings
mount(container, state)
Mount reactive state to a container using r- attribute directives:
| Directive | Shorthand | Description | Example |
|---|---|---|---|
{{ expr }} |
Text interpolation | Hello {{ name }}! |
|
r-text |
Text content | r-text="name" |
|
r-html |
Inner HTML | r-html="richContent" |
|
r-show |
Show/hide | r-show="isVisible" |
|
r-if |
@if |
Conditional render | @if="showSection" |
r-else-if |
@else-if |
Chained condition | @else-if="other" |
r-else |
@else |
Fallback | @else |
r-for |
@for |
List rendering | @for="item in items" |
r-transition |
CSS transitions | r-transition="fade" |
|
r-class:name |
.name |
Toggle class | .active="isActive" |
.name_50 |
With opacity | .bg-red-500_50="err" |
|
r-style:prop |
Inline style | r-style:color="textColor" |
|
r-attr:name |
:name |
Attribute | :disabled="loading" |
r-on:event |
@event |
Event handler | @click="count++" |
@event.mod |
With modifiers | @submit.prevent |
|
r-model |
Two-way binding | r-model="inputValue" |
|
:class |
Class object | :class="{ active: isOn }" |
|
:style |
Style object | :style="{ color: c }" |
bind(element, config)
Programmatic binding to a single element:
bind(element, {
text: () => state.label,
class: { active: () => state.isActive },
style: { opacity: () => state.isVisible ? '1' : '0' },
attr: { disabled: () => state.loading ? '' : null },
show: () => !state.hidden,
on: { click: (e) => state.count++ },
});Event Modifiers
Chain modifiers after the event name:
<form @submit.prevent="save()"> <!-- preventDefault -->
<button @click.stop="handle()"> <!-- stopPropagation -->
<button @click.once="init()"> <!-- runs once only -->
<input @keydown.enter="submit()"> <!-- only on Enter key -->
<input @keydown.esc="cancel()"> <!-- only on Escape key -->
<div @click.capture="log()"> <!-- capture phase -->
<form @submit.prevent.stop="save()"> <!-- combine modifiers -->Available modifiers:
- Event:
.prevent,.stop,.once,.capture,.passive - Keys:
.enter,.esc,.tab,.space,.up,.down,.left,.right,.delete
Transitions
Add CSS transitions to @if elements:
<div @if="show" r-transition="fade">Animated content</div>
<style>
/* Enter transition */
.fade-enter-from { opacity: 0; }
.fade-enter-active { transition: opacity 0.3s ease; }
.fade-enter-to { opacity: 1; }
/* Leave transition */
.fade-leave-from { opacity: 1; }
.fade-leave-active { transition: opacity 0.3s ease; }
.fade-leave-to { opacity: 0; }
</style>Works with @else-if and @else chains too:
<div @if="mode === 'a'" r-transition="slide">A</div>
<div @else-if="mode === 'b'" r-transition="slide">B</div>
<div @else r-transition="slide">C</div>Utilities
toRaw(proxy)
Get the original object from a reactive proxy.
isReactive(value)
Check if a value is a reactive proxy.
flushSync()
Force synchronous flush of pending effects (useful for testing).
cleanupElement(element)
Manually trigger cleanup for a DOM element's bindings.
How It Works
Pull-Based Architecture
State changes → Mark dependents as stale (cheap)
↓
Someone reads a value → Recompute only what's needed → Return resultIf you have 100 computed values but only 3 are visible, only 3 recompute.
Automatic Memory Management
// No need for dispose(), unsubscribe(), or onUnmounted()
// When the DOM element is garbage collected, bindings clean up automatically
function createWidget() {
const el = document.createElement('div');
const state = reactive({ count: 0 });
bind(el, { text: () => state.count });
document.body.appendChild(el);
// Later: document.body.removeChild(el)
// GC will automatically clean up the binding — no leaks possible
}Browser Support
Requires support for:
Proxy(ES2015+)WeakRef&FinalizationRegistry(ES2021+)queueMicrotask
Supported in all modern browsers (Chrome 84+, Firefox 79+, Safari 14.1+, Edge 84+).
Development
npm install
npm run build
npm testLicense
MIT