Concepts

Philosophy

Micra is intentionally small: a shallow reactive proxy, a fixed set of data-* directives, a tiny CSP-safe expression surface, and a keyed list diff — about 7 KB gzip, no build step. That ceiling is a feature. Deep nested reactivity, a client router, and heavyweight data/UI helpers are deliberately out of the core — the path-write sugar (this.set), the component catalog, and copy-paste recipes cover the real cases without growing the engine.

It’s the layer you reach for to make server-rendered HTML reactive — instead of sprinkling jQuery, instead of Alpine, and instead of standing up a React SPA for an admin panel. Not a total framework replacement; the right tool for that niche.

Reactive state

Micra wraps your state object in a shallow Proxy.

this.state.count = 1

This re-renders. This does not:

this.state.user.name = 'Ana'

Replace the top-level property instead:

this.state.user = { ...this.state.user, name: 'Ana' }

Or use the path sugar — it reconstructs the nested object and reassigns the top-level key for you (still shallow, just less typing):

this.set('user.name', 'Ana')          // ≡ the spread above
<input data-model="user.name" />      <!-- two-way bind to a nested path -->

Arrays follow the same rule. Replace them instead of mutating in place:

this.state.items = [...this.state.items, nextItem]

Batch scheduler

Micra batches synchronous state writes into one microtask render.

this.state.loading = true
this.state.page = 2
this.state.query = 'billing'

These writes trigger one render, not three. The scheduler uses queueMicrotask(...), so updates flush after the current call stack.

Directive cache

On the first render, Micra scans the component root and records directive bindings such as:

That scan result is cached on the root element. Later renders reuse the cached binding list instead of calling querySelectorAll() again.

Result: first render pays the scan cost; re-renders are much faster.

Expression evaluator

Most directives accept JavaScript expressions:

<div data-if="count > 0"></div>
<a data-bind="href:'/users/' + user.id"></a>

Micra evaluates expressions against a state object using two paths:

  1. Fast path for simple property access: expressions like count, user.name, and item.email are resolved directly by walking the path.
  2. Parsed path for full expressions: other expressions (count > 0, ternaries, comparisons, method calls) are tokenized and parsed into a small AST once, then walked by a built-in interpreter.

Both paths parse and interpret — there is no new Function or eval, so Micra runs under a strict Content-Security-Policy (default-src 'self', no unsafe-eval). The AST is cached per expression string (global for the page), so reused expressions stay fast.

The grammar is deliberately small: property access, calls, comparisons, ternaries, the usual operators — but no object or array literals. data-each="items || []" and @click="f({ a: 1 })" won’t parse. data-each already renders nothing for a null/non-array value, and object arguments belong in a method. Keeping the surface small is intentional — logic lives in methods, not in markup.

@event handlers may also be call expressions with arguments — @click="select(item.id)", @input="set($event.target.value)" — evaluated against the same scope plus the row item and $event.

exprState

During rendering, expressions see more than raw state. Micra creates an expression proxy that exposes:

That means expressions can call methods:

<time data-text="formatDate(user.createdAt)"></time>
formatDate(value: string) {
  return new Date(value).toLocaleDateString()
}

Keyed diff algorithm

data-each renders lists from a <template>.

<template data-each="items" data-key="id">
  <li data-text="item.name"></li>
</template>

With data-key, Micra uses keyed diffing:

  1. read the next item list
  2. build the next key order
  3. reuse existing DOM nodes when keys match
  4. create nodes only for new keys
  5. reorder nodes with insertBefore
  6. remove nodes for missing keys

This keeps DOM state stable and avoids full list replacement.

Without data-key, Micra falls back to a positional reuse diff: the first min(prev, next) row nodes are kept in place, the tail is removed when the list shrinks, and fresh rows are cloned for the growth delta. Reused rows get a re-applied directive pass through their cached scan, so content updates without remove-and-re-clone overhead. Compared to keyed diffing this still misses two things: identity is positional (reordering treats the entire list as “every row changed”), and rows with more than one top-level node are wrapped in <micra-each-item style="display:contents"> so each row corresponds to one stable DOM node. Use data-key when row identity matters (reordering, animation, preserving focus on a moved row).

Multiple instances of the same component

A single Micra.define() call registers a definition — a blueprint. Every [data-component] element gets its own isolated instance with its own state.

This is how you put multiple dropdowns, tooltips, or accordion panels on the same page without any duplication.

How it works

Each data-component="dropdown" element mounts independently. The definition is shared (no memory cost), but state is per-element:

<!-- Three independent dropdowns — one definition, three instances -->
<div data-component="dropdown" data-label="Sort by">...</div>
<div data-component="dropdown" data-label="Filter by status">...</div>
<div data-component="dropdown" data-label="Per page">...</div>
Micra.define('dropdown', {
  state: { open: false, selected: null },

  // Read initial label from the HTML attribute
  onCreate() {
    this.state.label = this.prop('label', 'Choose...')
  },

  toggle() { this.state.open = !this.state.open },

  select(e: Event) {
    const item = (e.currentTarget as HTMLElement).dataset['value']
    this.state.selected = item
    this.state.open = false
    this.emit('dropdown:change', { id: this.$el.id, value: item })
  },

  onDestroy() {
    // cleanup (e.g. outside-click listener)
  },
})

Each instance has:

Communicating between instances

Use the event bus. Any component can publish or subscribe:

// Dropdown publishes a change
this.emit('dropdown:change', { id: this.$el.id, value })

// Table subscribes to react
this.on('dropdown:change', ({ id, value }) => {
  if (id === 'status-filter') this.state.statusFilter = value
})

Props from data-attributes

this.prop(name, default) reads data-* attributes from the root element — useful for per-instance configuration set by the server:

<div data-component="chart"
     data-endpoint="/api/revenue"
     data-period="30"
     data-type="line">
</div>
<div data-component="chart"
     data-endpoint="/api/signups"
     data-period="7"
     data-type="bar">
</div>
Micra.define('chart', {
  state: { data: [] },
  async onCreate() {
    const endpoint = this.prop('endpoint', '/api/data')
    const period   = this.prop('period', 30)  // auto-cast to number
    this.state.data = await this.fetch(endpoint, { period })
  },
})

Accessing a specific instance

Micra.instances() returns a Map<HTMLElement, ComponentInstance>. Look up by element:

const el = document.getElementById('sort-dropdown')!
const inst = Micra.instances().get(el)
inst?.state.open = true