JSPM

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

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

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 latchjs

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

Custom 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 reactivity

computed(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 before

Supports 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 effect

watch(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 result

If 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 test

License

MIT