npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@matheusmntt/ion

v1.0.4

Published

A tiny reactive micro-framework for server-rendered applications

Downloads

439

Readme

  ██╗ ██████╗ ███╗   ██╗
  ██║██╔═══██╗████╗  ██║
  ██║██║   ██║██╔██╗ ██║
  ██║██║   ██║██║╚██╗██║
  ██║╚██████╔╝██║ ╚████║
  ╚═╝ ╚═════╝ ╚═╝  ╚═══╝

A tiny reactive micro-framework for server-rendered applications.

Ion adds reactive state to any HTML page — no virtual DOM, no build step required, no framework takeover. Drop it into a PHP, Laravel, or any server-rendered application and get fine-grained reactivity exactly where you need it.

<!-- Your server-rendered HTML stays untouched -->
<div data-counter>
  <button>+</button>
  <span data-bind="count"></span>
</div>

<script src="dist/ion.global.js"></script>
<script>
  const counter = Ion.App.store('counter', { count: 0 })

  Ion.App.component('[data-counter]', (el) => {
    el.querySelector('button').addEventListener('click', () => {
      counter.state.count++
    })
  })

  Ion.App.start()
</script>

Table of Contents


Why Ion?

Modern frameworks solve complex problems — but most server-rendered apps don't have complex problems. They need a dropdown that opens, a counter that increments, a form that validates in real time.

Ion is the answer to: "I just need some reactivity on this PHP page."

| | Ion | Alpine.js | Vue | React | |---|---|---|---|---| | Bundle (min+gz) | ~3 KB | ~15 KB | ~34 KB | ~45 KB | | Virtual DOM | No | No | Yes | Yes | | Build step required | No | No | Optional | Yes | | Server-rendered friendly | ✓ | ✓ | Partial | Partial | | TypeScript-first | ✓ | Partial | ✓ | ✓ | | Framework takeover | No | No | Yes | Yes |

Ion focuses on three things: reactive state, declarative bindings, and clean component lifecycle. Nothing more.


Installation

npm / yarn / pnpm:

npm install @matheusmntt/ion

CDN (no build step):

<!-- IIFE — exposes window.Ion -->
<script src="https://unpkg.com/@matheusmntt/ion/dist/ion.global.js"></script>

<!-- ESM -->
<script type="module">
  import { App } from 'https://unpkg.com/@matheusmntt/ion/dist/ion.js'
</script>

Local build:

git clone https://github.com/matheusmntt/ion
cd ion
npm install
npm run build
# → dist/ion.global.js  (IIFE, for <script src>)
# → dist/ion.js         (ESM, for <script type="module">)
# → dist/ion.cjs        (CJS, for require())

Core Concepts

Ion is built on three primitives. Understanding them is all you need.

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   Store ──► holds reactive state                           │
│     │                                                       │
│     └──► Bindings ──► sync state to DOM automatically      │
│     │                                                       │
│     └──► Components ──► attach behavior to elements        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Stores are the single source of truth. Bindings and components read from them, and mutations propagate automatically.


Stores

Creating a Store

A store wraps a plain JavaScript object in a reactive proxy. Any mutation to store.state triggers downstream updates.

// Named store — registered on App, accessible via App.stores
const counter = App.store('counter', {
  count: 0
})

// Standalone store — not registered on App
import { createStore } from '@matheusmntt/ion'

const localStore = createStore({ count: 0 })

The first argument of App.store is a name. That name lets you retrieve the store later from anywhere:

const counter = App.getStore('counter')

Reading State

Read properties directly from store.state:

console.log(counter.state.count) // 0

Nested objects are fully reactive:

const store = App.store('user', {
  profile: {
    name: 'Ana',
    address: {
      city: 'São Paulo'
    }
  }
})

console.log(store.state.profile.name)         // 'Ana'
console.log(store.state.profile.address.city) // 'São Paulo'

Note: The same nested object always returns the same proxy reference. store.state.profile === store.state.profile is always true.


Mutating State

Assign directly to any property at any depth:

counter.state.count = 5
counter.state.count++

store.state.profile.name = 'João'
store.state.profile.address.city = 'Curitiba'

There is no setState, no commit, no dispatch. Ion uses Proxy internally to intercept mutations and schedule notifications automatically.


Subscribing to Changes

subscribe fires whenever any property in the store changes. The callback receives the updated state.

const unsubscribe = counter.subscribe((state) => {
  console.log('state changed:', state.count)
})

counter.state.count++ // → "state changed: 1"

// Stop listening
unsubscribe()

Subscriptions are deduped and batched in microtasks — multiple synchronous mutations in a row produce a single notification per subscriber.


onChange — Full Change Details

When you need to know which property changed and its new value, use onChange:

const unlisten = store.onChange((prop, value, state) => {
  console.log(`"${String(prop)}" → ${value}`)
})

store.state.profile.name = 'João'
// → "name" → João

unlisten()

Both subscribe and onChange return a cleanup function. Always call it when you no longer need the subscription to prevent memory leaks.


Watching a Specific Path

watch lets you target a specific property — or a nested path using dot notation. The callback only fires when that path's value actually changes.

// Top-level property
counter.watch('count', (value, state) => {
  console.log('count is now', value)
})

// Nested path — works correctly, unlike naive implementations
store.watch('profile.name', (value) => {
  document.title = value
})

store.watch('profile.address.city', (value) => {
  console.log('city changed to', value)
})

store.state.profile.name = 'João'         // fires 'profile.name' watcher
store.state.profile.address.city = 'RJ'  // fires 'profile.address.city' watcher
store.state.profile.name = 'João'         // does NOT fire — value is the same

watch uses value diffing: the callback only runs if the resolved value actually changed, preventing spurious updates.

// All watch calls return an unwatch function
const unwatch = counter.watch('count', callback)
unwatch()

Computed Properties

Computed properties are derived values that are automatically cached and only recalculated when their dependencies change.

const store = App.store(
  'cart',
  {
    items: [
      { name: 'Coffee', price: 12.9 },
      { name: 'Book',   price: 49.0 },
    ],
    discount: 0.1,
  },
  {
    computed: {
      // Simple function form
      total(state) {
        return state.items.reduce((sum, item) => sum + item.price, 0)
      },

      // With discount applied
      finalPrice(state) {
        return state.total * (1 - state.discount)
      },
    },
  }
)

console.log(store.state.total)      // 61.9
console.log(store.state.finalPrice) // 55.71

// Accessing .total again → returns cached value (no recalculation)
console.log(store.state.total) // 61.9 (from cache)

Computed values are lazy (calculated on first read) and dirty-tracked (invalidated when a dependency changes). They behave like regular state properties for bindings:

<span data-bind="total"></span>
<span data-bind="finalPrice"></span>

DOM Bindings

Bindings are HTML attributes that connect the DOM to a store. They update automatically whenever the state changes.


data-bind — Text

Sets the textContent of an element to the value at a given path.

<span data-bind="count"></span>
<span data-bind="user.name"></span>
<span data-bind="cart.finalPrice"></span>
const store = App.store('ui', {
  count: 0,
  user: { name: 'Ana' },
})

App.bind(document.body, store)

store.state.user.name = 'João'
// → <span>João</span>

data-show — Visibility

Toggles element.hidden based on the truthiness of the value at the given path.

<div data-show="isLoggedIn">
  Welcome back!
</div>

<div data-show="cart.hasItems">
  Your cart has items.
</div>

When the value is falsy, the element is hidden via the native hidden attribute. When truthy, hidden is removed.


data-model — Two-Way Input

Binds an <input>, <textarea>, or <select> value to the store. Reads from the store and writes back on user input.

<input type="text"     data-model="search.query">
<input type="checkbox" data-model="settings.darkMode">
<textarea              data-model="form.message"></textarea>
<select                data-model="form.country">
  <option value="br">Brazil</option>
  <option value="us">United States</option>
</select>
const store = App.store('form', {
  search: { query: '' },
  settings: { darkMode: false },
  form: { message: '', country: 'br' },
})

App.bind(document.body, store)

// User types "Ion" → store.state.search.query === 'Ion'
// store.state.settings.darkMode = true → checkbox becomes checked

For checkboxes, Ion binds to .checked. For all other inputs, it binds to .value.


data-attr — Attribute Binding

Binds a state value to an HTML attribute. Syntax: "path:attributeName".

<img data-attr="user.avatar:src">
<a  data-attr="nav.href:href">Dashboard</a>
<div data-attr="ui.role:aria-label"></div>

If the value is null or undefined, the attribute is removed from the element. Otherwise, it is set to String(value).


data-class — Class Toggle

Conditionally toggles a CSS class. Syntax: "path:className".

<button data-class="ui.isLoading:loading">Submit</button>
<div    data-class="form.isValid:valid">...</div>
<li     data-class="item.isActive:active">...</li>
store.state.ui.isLoading = true
// → <button class="loading">Submit</button>

store.state.ui.isLoading = false
// → <button>Submit</button>

data-for — List Rendering

Renders a list of elements from an array in the store. The parent element acts as the container; one child with data-for-item acts as the template.

<ul data-for="todos">
  <li data-for-item>
    <span data-bind="text"></span>
    <em data-show="done">✓</em>
  </li>
</ul>
const store = App.store('tasks', {
  todos: [
    { text: 'Buy groceries', done: true  },
    { text: 'Write docs',    done: false },
  ],
})

App.bind(document.body, store)

Each item in the array is exposed as a scoped store inside the template. The available properties are the item's own properties plus $index.

| Variable | Value | |---|---| | text | The item's text property | | done | The item's done property | | $index | The item's position in the array (0-based) |

When store.state.todos is reassigned, the list re-renders automatically.

Note: data-for performs a full re-render on every change. For high-frequency updates on large lists, a component-based approach is preferred.


applyBindings — Manual Binding

You can connect a store to any DOM subtree manually, bypassing the App singleton.

import { applyBindings, createStore } from '@matheusmntt/ion'

const store = createStore({ name: 'Ana', visible: true })

// Apply to a specific root — not necessarily document.body
const cleanup = applyBindings(
  document.querySelector('#my-widget'),
  store
)

// Later, disconnect everything
cleanup()

applyBindings scans the root element and all its descendants for binding attributes, connects them, and sets up a MutationObserver to handle elements added to the DOM after the initial scan.


Components

Components attach JavaScript behavior to DOM elements. They are defined by a CSS selector and a factory function.

Registering a Component

App.component('[data-counter]', (el) => {
  const store = App.getStore('counter')
  const button = el.querySelector('button')

  const increment = () => store.state.count++
  button.addEventListener('click', increment)

  // Return a cleanup object to free resources when destroyed
  return {
    cleanup() {
      button.removeEventListener('click', increment)
    },
  }
})

The factory receives the matched HTMLElement and runs once per element. It can optionally return { cleanup }.

App.component() is chainable:

App
  .component('[data-counter]', counterFactory)
  .component('[data-modal]',   modalFactory)
  .component('[data-tabs]',    tabsFactory)

Component Lifecycle

App.start() / App.mount(el)
        │
        ▼
   factory(el) called
        │
        ▼
   instance stored internally (WeakMap — no DOM pollution)
        │
        ▼
   App.destroy(el) called
        │
        ▼
   instance.cleanup() called
        │
        ▼
   instance removed from registry

Ion stores component instances in a WeakMap keyed by element. This means:

  • No el.__cleanup properties attached to DOM nodes
  • No conflicts with third-party libraries
  • Garbage collector automatically cleans up when elements are removed

Initializing the App

After registering all stores and components, call App.start() once:

const counter = App.store('counter', { count: 0 })
const user    = App.store('user',    { name: 'Ana' })

App.component('[data-counter]', counterFactory)
App.component('[data-user]',    userFactory)

// Scans the entire document and mounts all registered components
App.start()

Dynamic Mount

For content injected into the DOM after App.start() (e.g., HTMX responses, modal content):

// Insert dynamic content
const section = document.createElement('section')
section.innerHTML = serverResponse
document.body.appendChild(section)

// Mount only the new subtree — already-mounted components are unaffected
App.mount(section)

Destroying Components

// Destroy a single component
App.destroy(document.querySelector('[data-counter]'))

// Destroy all components in a subtree (useful when removing DOM sections)
App.destroySubtree(document.querySelector('#modal'))

destroySubtree walks the entire subtree and calls cleanup() on every mounted component it finds.


Reactivity Primitives

Ion exposes two low-level primitives for advanced use cases.

effect

effect runs a callback immediately and re-runs it automatically whenever any reactive state it reads changes. Dependencies are tracked automatically — you do not declare them.

const store = App.store('page', { title: 'Home', visits: 0 })

const stop = App.effect(() => {
  // This reads store.state.title — it becomes a dependency automatically
  document.title = store.state.title
})

store.state.title = 'About'
// → document.title === 'About' (effect re-ran)

store.state.visits++
// → effect does NOT re-run (visits was never read inside the effect)

// Permanently disable the effect
stop()

The callback may return a cleanup function, which Ion calls before each re-run and on final stop:

App.effect(() => {
  const handler = () => console.log(store.state.count)
  window.addEventListener('focus', handler)

  return () => {
    window.removeEventListener('focus', handler)
  }
})

batch

batch groups multiple state mutations into a single notification cycle. Useful when updating several properties that should appear as a single atomic change.

const store = App.store('form', {
  firstName: '',
  lastName: '',
  email: '',
})

// Without batch: 3 separate notification cycles → 3 DOM updates
store.state.firstName = 'Ana'
store.state.lastName  = 'Lima'
store.state.email     = '[email protected]'

// With batch: 1 notification cycle → 1 DOM update
App.batch(() => {
  store.state.firstName = 'Ana'
  store.state.lastName  = 'Lima'
  store.state.email     = '[email protected]'
})

batch calls are nestable. Notifications flush only when the outermost batch completes.

App.batch(() => {
  store.state.a = 1
  App.batch(() => {
    store.state.b = 2  // still inside outer batch
  })
  store.state.c = 3
})
// → single flush here

Plugin System

Ion's plugin API allows registering custom binding directives.

const TooltipPlugin = {
  install(ctx) {
    ctx.registerBinding('data-tooltip', (el, expression) => {
      el.setAttribute('title', expression)
      el.setAttribute('aria-label', expression)
    })
  },
}

App.use(TooltipPlugin)

After installing, data-tooltip works anywhere in your HTML:

<button data-tooltip="Save your work">Save</button>

The binding handler signature:

type BindingHandler = (
  el: HTMLElement,
  expression: string,      // the raw attribute value
  stores: Record<string, unknown>  // all registered stores
) => Cleanup | void

If the handler returns a function, Ion calls it as cleanup when the element is removed from applyBindings scope.

App.use() is idempotent — installing the same plugin twice is a no-op.

App
  .use(TooltipPlugin)
  .use(LazyImagePlugin)
  .use(AnimationPlugin)

TypeScript

Ion is written in TypeScript. State shapes are fully inferred.

interface CartItem {
  name: string
  price: number
  quantity: number
}

interface CartState {
  items: CartItem[]
  coupon: string | null
  discount: number
}

const cart = App.store<CartState>('cart', {
  items: [],
  coupon: null,
  discount: 0,
})

// Fully typed — IDE autocomplete works on store.state
cart.state.items.push({ name: 'Coffee', price: 12.9, quantity: 1 })
cart.state.discount = 0.15

// watch callback value is typed as unknown by default
// you can narrow it:
cart.watch('discount', (value) => {
  const pct = value as number
  console.log(`Discount: ${pct * 100}%`)
})

Computed properties:

const store = createStore(
  { price: 100, tax: 0.1 },
  {
    computed: {
      total: (state): number => state.price * (1 + state.tax),
    },
  }
)

Plugin with typed binding:

import type { Plugin, BindingHandler } from '@matheusmntt/ion'

const MyPlugin: Plugin = {
  install(ctx) {
    const handler: BindingHandler = (el, expression) => {
      // ...
    }
    ctx.registerBinding('data-my-directive', handler)
  },
}

Using Without a Bundler

Ion ships a self-contained IIFE build. Load it with a <script> tag and everything is available under the Ion global.

<!DOCTYPE html>
<html>
<head>
  <title>My PHP App</title>
</head>
<body>

  <!-- Server-rendered content -->
  <div data-user>
    <p>Hello, <span data-bind="name"></span>!</p>
    <input data-model="name" type="text" placeholder="Your name">
  </div>

  <script src="/vendor/ion/dist/ion.global.js"></script>
  <script>
    const { App } = Ion

    const user = App.store('user', {
      name: '<?= htmlspecialchars($user->name) ?>'
    })

    App.bind(document.querySelector('[data-user]'), user)
  </script>

</body>
</html>

For <script type="module">:

<script type="module">
  import { App } from '/vendor/ion/dist/ion.js'

  const store = App.store('counter', { count: 0 })
  // ...
</script>

Architecture

index.ts
└── src/
    ├── app.ts          AppCore singleton + public API
    ├── store.ts        createStore — reactive state container
    ├── bindings.ts     DOM binding directives + applyBindings
    ├── component.ts    Component registry + lifecycle management
    ├── reactivity.ts   Proxy engine, scheduler, effect, batch
    └── types.ts        TypeScript interfaces and type definitions

Reactivity pipeline

store.state.count = 1
        │
        ▼
  deepReactive Proxy (set trap)
        │
        ▼
  notify(prop, value)
        │
  ┌─────┴──────────────────────────────────┐
  ▼                                        ▼
subscribers                           watchers
  │                                    (path diff)
  └─────────────────┬──────────────────────┘
                    ▼
              queueNotification()
                    │
          ┌─────────┴─────────┐
          │ inside batch?     │
          ▼                   ▼
    push to queue        schedule()
                              │
                              ▼
                        queueMicrotask
                              │
                              ▼
                    flush all callbacks

Key design decisions:

  • Proxy-based, no compilation. Ion never parses your templates or transforms your code. Reactivity is pure runtime.
  • WeakMap proxy cache. The same raw object always returns the same proxy, so reference equality is preserved.
  • Microtask batching. Multiple synchronous mutations in a tick produce one flush. The scheduler deduplicates identical callbacks.
  • Loop detection. If a subscriber triggers mutations that re-trigger subscribers indefinitely, Ion throws after 100 nested flush cycles instead of hanging the browser.
  • WeakMap component registry. Component instances and cleanup functions are stored in WeakMaps, not as properties on DOM nodes. No conflicts, no leaks.

API Reference

App

| Method | Signature | Description | |---|---|---| | store | (name, initialState, options?) → Store | Creates and registers a named store | | getStore | (name) → Store | Retrieves a registered store by name | | component | (selector, factory) → this | Registers a component factory | | bind | (root, store) → Cleanup | Applies bindings from a store to a DOM subtree | | effect | (callback) → Cleanup | Creates a self-tracking reactive effect | | batch | (fn) → void | Groups mutations into a single notification cycle | | use | (plugin) → this | Installs a plugin | | start | () → void | Mounts all registered components in the document | | mount | (root) → void | Mounts components in a specific subtree | | destroy | (el) → void | Destroys a single component instance | | destroySubtree | (el) → void | Destroys all components in a subtree |


Store<T>

| Property/Method | Signature | Description | |---|---|---| | state | T | The reactive state proxy | | subscribe | (fn: (state) → void) → Cleanup | Subscribe to any change | | onChange | (fn: (prop, value, state) → void) → Cleanup | Subscribe with full change details | | watch | (path, fn: (value, state) → void) → Cleanup | Watch a specific path (dot-notation) |


createStore(initialState, options?)

Standalone store factory — not registered on App.

createStore<T extends State>(
  initialState: T,
  options?: {
    computed?: Record<string, (state: T) => unknown>
  }
): Store<T>

DOM Binding Directives

| Attribute | Syntax | Description | |---|---|---| | data-bind | "path" | Sets textContent | | data-show | "path" | Toggles hidden | | data-model | "path" | Two-way value binding (input, textarea, select) | | data-attr | "path:attrName" | Sets an HTML attribute | | data-class | "path:className" | Toggles a CSS class | | data-for | "arrayPath" | Renders a list; child with data-for-item is the template |


applyBindings(root, store, customBindings?)

applyBindings<T extends State>(
  root: HTMLElement,
  store: Store<T>,
  customBindings?: Map<string, BindingHandler>
): Cleanup

Scans root and its subtree, applies all matching directives, and sets up a MutationObserver for dynamically added elements. Returns a cleanup function that disconnects everything.


effect(callback)

effect(callback: () => void | (() => void)): Cleanup

Runs callback immediately and re-runs it when any reactive property it reads changes. Returns a stop function.


batch(fn)

batch(fn: () => void): void

Executes fn synchronously, deferring all notifications until it returns. Nestable.


Plugin Interface

interface Plugin {
  install(ctx: PluginContext): void
}

interface PluginContext {
  registerBinding(attribute: string, handler: BindingHandler): void
}

type BindingHandler = (
  el: HTMLElement,
  expression: string,
  stores: Record<string, unknown>
) => Cleanup | void

License

MIT © Matheus Monteiro