JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 4
  • Score
    100M100P100Q21069F
  • License MIT

A pull-based reactive system with lazy evaluation, automatic memory management, and declarative DOM bindings

Package Exports

  • latchjs

Readme

latchjs

A pull-based reactive system with automatic memory management

npm version bundle size license

Quick StartAPI ReferenceExamples


Features

  • Pull-Based Reactivity: Values recompute only when read, not when dependencies change
  • Automatic Memory Management: Uses WeakRef + FinalizationRegistry for cleanup without manual dispose
  • AsyncIterator Streams: Consume reactive changes with for await...of and natural backpressure
  • No Build Step Required: Works directly in browsers via ES modules or IIFE
  • Declarative DOM Bindings: @click, :class, @for, @if directives work at runtime
  • Component System: Props, slots, events, scoped styles, and lifecycle hooks

Install

npm install latchjs

Or 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 examples

TypeScript

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 typecheck

License

MIT