← All posts

Porting attribute-driven code to Micra: a syntax map and six steps

Libraries that declare behavior as attributes on server-rendered HTML all feel related. A click handler here, a visibility condition there — the prefixes differ, but the shape is the same: markup carries the wiring, a small runtime reads it and keeps the DOM in sync. Alpine is the best-known of the family. Micra is another one, and I wrote it, so take everything below with that in mind.

Because the paradigm is shared, moving code between the two is mostly mechanical: a table of substitutions plus a handful of genuine differences. Below is the table, the conversion steps for a typical app, and the places where the mechanics actually diverge — the part that costs time if you find out mid-port.

One thing up front: none of this is an argument to move. If your current code works, keep it. Porting makes sense when you already want to try Micra on a real page and rewriting from scratch is the only thing in the way.

The syntax map

I’ll use Alpine’s syntax as the left column, since that is what people ask me about; if your attributes carry a different prefix, the mapping reads the same way.

You write In Micra
x-data="{ count: 0 }" data-component="counter" + Micra.define("counter", { state: { count: 0 } })
x-text="count" data-text="count"
x-html="markup" data-html="markup"
x-show="open" data-show="open"
x-if="open" (on a <template>) data-if="open" (directly on the element)
x-bind:href="url" data-bind="href:url"
x-bind:class="active && 'on'" data-class="on:active"
x-model="name" data-model="name"
@click="open = !open" @click="toggle" — the handler is a method
@keydown.enter, @click.prevent same, as written
x-init="load()" onCreate() lifecycle hook
x-ref="input" + $refs.input data-ref="input" + this.refs.input
a global store + $store.x this.emit(…) + this.on(…) — event bus
Alpine.start() Micra.start()

The map covers most substitutions. The one structural change doesn’t fit in a table: state and methods move out of the markup into a single define() call. Templates stay in HTML; logic collects in one JS object per component. The steps below walk through what that looks like on real fragments.

Step 1 — the script

<script src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>

This exposes a global Micra. If you are on npm instead:

npm install micra.js
import * as Micra from "micra.js";

Step 2 — the smallest component

A counter, in the inline style:

<div x-data="{ count: 0 }">
  <button @click="count--">−</button>
  <span x-text="count"></span>
  <button @click="count++">+</button>
</div>

The same component in Micra:

<div data-component="counter">
  <button @click="dec">−</button>
  <span data-text="count"></span>
  <button @click="inc">+</button>
</div>

<script>
  Micra.define("counter", {
    state: { count: 0 },
    inc() {
      this.state.count++;
    },
    dec() {
      this.state.count--;
    },
  });
  Micra.start(); // scans for [data-component] and mounts everything
</script>

Two inline mutations became two named methods — that is the whole shape change, and it repeats through every step. It costs vertical space; in exchange, every component ends up with the same outline: raw data in state, behavior below it.

Step 3 — a list

Loops move from the element to a <template> with data-each and a mandatory data-key:

<ul data-component="todos">
  <template data-each="todos" data-key="id">
    <li data-text="item.text"></li>
  </template>
</ul>

<script>
  Micra.define("todos", {
    state: { todos: [{ id: 1, text: "Pay rent" }] },
  });
  Micra.start();
</script>

Two mechanical differences. The loop variable is always item (with index and $index alongside), not a name you pick. And updates replace the array instead of mutating it:

// add
this.state.todos = [...this.state.todos, next];

// remove
this.state.todos = this.state.todos.filter((t) => t.id !== id);

// update — replace the changed item object, not just the array
this.state.todos = this.state.todos.map((t) =>
  t.id === id ? { ...t, done: true } : t,
);

Why replacement matters is pitfall 2 below.

Step 4 — shared state

Where your code shares state between components through a global store, the Micra equivalent is the built-in event bus. One component emits, another listens:

<button data-component="save-btn" @click="save">Save</button>

<div data-component="toast-stack">
  <template data-each="toasts" data-key="id">
    <div data-text="item.msg"></div>
  </template>
</div>

<script>
  Micra.define("save-btn", {
    save() {
      this.emit("toast", { msg: "Saved" });
    },
  });

  Micra.define("toast-stack", {
    state: { toasts: [] },
    onCreate() {
      let nextId = 0;
      // subscriptions made via this.on are removed automatically on destroy
      this.on("toast", ({ msg }) => {
        this.state.toasts = [...this.state.toasts, { id: ++nextId, msg }];
      });
    },
  });
  Micra.start();
</script>

The port here is a re-shape rather than a substitution: the store’s fields become state of whichever component owns them, and cross-component writes become events.

Step 5 — server data

Attribute-driven code usually receives server data through data-* attributes on the component root. Micra reads them with this.prop():

<div data-component="user-card" data-user-id="42" data-user='{"name":"Ada"}'>
  <h1 data-text="user.name"></h1>
</div>

<script>
  Micra.define("user-card", {
    state: { user: null },
    onCreate() {
      const id = this.prop("userId"); // 42 — numeric strings and "true"/"false" auto-cast
      this.state.user = JSON.parse(this.prop("user")); // JSON does NOT auto-parse
    },
  });
  Micra.start();
</script>

Primitives are cast for you; anything JSON-shaped you parse yourself in onCreate. If a value must stay a string — a zip code with a leading zero — pass a string default: this.prop("zip", "") returns the raw attribute.

Step 6 — init and cleanup

x-init code moves into onCreate(), which runs after mount with refs available. The symmetric hook is onDestroy(). What you need to know about cleanup: listeners declared in markup (@click, data-on, data-model) and subscriptions made with this.on are tracked and removed on destroy for you. Anything manual — timers, a document-level outside-click listener — is yours to pair up:

onCreate() {
  this._outside = (e) => {
    if (!this.$el.contains(e.target)) this.close();
  };
  document.addEventListener("click", this._outside);
},
onDestroy() {
  document.removeEventListener("click", this._outside);
},

For loading data there is this.fetch() — a small wrapper over native fetch that JSON-serializes bodies, attaches the CSRF token from <meta name="csrf-token"> on same-origin requests, and throws a typed error on non-2xx. Plain fetch works too; the wrapper just saves the boilerplate on server-rendered stacks.

The pitfalls

The differences above are substitutions. These are the ones that break silently or half-work if you carry the old habits across.

  1. No inline mutations. @click="open = !open" will not run — the expression grammar has no assignment, by design. State changes go through methods. Calls with arguments are fine in handlers: @click="select(item.id)".

  2. Reactivity is shallow. this.state.user.name = "Ada" lands on the object but triggers no render. Replace the top-level key (this.state.user = { ...this.state.user, name: "Ada" }) or use this.set("user.name", "Ada"), which rebuilds the path for you. The array variant of the same trap: mutating an item in place and then copying the array (todos[0].done = true; this.state.todos = [...this.state.todos]) leaves that row unchanged on screen, because keyed diffing compares item references. Replace the item object, as in step 3.

  3. Row-level data-model and data-ref don’t work inside data-each. A data-model bound to the row (item.qty, index) is not row-scoped, and data-ref in a row is not collected; Micra warns about both in dev. A data-model that points at component state (data-model="draft") works anywhere, rows included. For an input per row, bind down and handle up: data-bind="value:item.qty" plus @input="setQty(item.id, $event.target.value)".

  4. data-if on the <template data-each> itself is inert (also a dev warning). Put the condition on a wrapper element around the template.

  5. Plugin-style behaviors have no packaged equivalent. If your page leans on drop-in plugins — intersection observers, collapse animations, focus traps — in Micra each becomes a few lines of your own in onCreate with cleanup in onDestroy. Whether that trade is acceptable depends on how many of them you actually use; this is the honest place where a port costs more than the table suggests.

Derived values deserve a mention here too, as a convention rather than a trap: counts, filtered subsets, and formatted labels are methods, not state fields. data-text="remaining()" with remaining() { return this.state.todos.filter(t => !t.done).length } — one source of truth, nothing to keep in sync.

After the port the page does the same thing; what moved is where the logic lives. Markup keeps the wiring, one object per component keeps the behavior. The table covers the bulk of the work, the pitfalls are the rest — and the directives I didn’t touch here are documented with runnable recipes at micrajs.dev.