@matheusmntt/ion
v1.0.4
Published
A tiny reactive micro-framework for server-rendered applications
Downloads
439
Maintainers
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?
- Installation
- Core Concepts
- Stores
- DOM Bindings
- Components
- Reactivity Primitives
- Plugin System
- TypeScript
- Using Without a Bundler
- Architecture
- API Reference
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/ionCDN (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) // 0Nested 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.profileis alwaystrue.
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 samewatch 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 checkedFor 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-forperforms 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 registryIon stores component instances in a WeakMap keyed by element. This means:
- No
el.__cleanupproperties 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 herePlugin 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 | voidIf 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 definitionsReactivity 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 callbacksKey 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>
): CleanupScans 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)): CleanupRuns callback immediately and re-runs it when any reactive property it reads changes. Returns a stop function.
batch(fn)
batch(fn: () => void): voidExecutes 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 | voidLicense
MIT © Matheus Monteiro
