May 26, 2026
Common mistakes in Micra — and how to fix them
Almost every Micra mistake grows from the same root: the library being used like jQuery or like React instead of the way it’s built. Below are eight such mistakes, most frequent first. If your code looks like a broken version, rewrite it — the correct one usually comes out shorter.
You don’t have to memorize eight separate rules; it’s enough to hold on to the two mechanisms they all follow from. The first is reactivity: this.state is wrapped in a shallow Proxy, and Micra learns about a change only when the write trap fires on a top-level key. The second is the scheduler: each such write doesn’t repaint immediately — it queues a single render as a microtask, so however many keys a method changes, there is one repaint. Half of the items below are attempts to get around the first mechanism; the other half are distrust of the second.
1. Mutating an array in place
Broken:
addTodo(text) {
this.state.todos.push({ id: Date.now(), text }) // ← invisible to Micra
}
Why it breaks: the Proxy wraps only the state object itself. It has two traps — one for writes, one for key deletion — and both see the top level only: state.todos = … gets intercepted, state.todos.push(…) does not. From the proxy’s point of view, .push() is a read of the todos key (which returns a plain array) followed by a write inside that array, which the trap no longer cares about. The data changed; nobody scheduled a render.
The unpleasant part is how this looks from the outside. Nothing gives it away: the array fills up honestly, the DOM just never hears about it. Then a write to any other key makes Micra re-render the component — and the “missing” todo suddenly appears. The bug looks intermittent, even though the cause is strictly deterministic.
The fix: replace the whole top-level key.
addTodo(text) {
this.state.todos = [...this.state.todos, { id: Date.now(), text }]
}
A new array is a write to state.todos; the trap fires, a render gets scheduled. The copy itself is cheap: it copies references to the items, not the objects, and next to actual DOM work it doesn’t register.
Same story with .splice, .sort, .reverse, .pop — all of them mutate in place. Always return a new array.
Three operations you’ll need constantly:
// add
this.state.items = [...this.state.items, next];
// remove
this.state.items = this.state.items.filter((i) => i.id !== id);
// update
this.state.items = this.state.items.map((i) =>
i.id === id ? { ...i, done: true } : i,
);
The “update” one has a non-obvious detail: the item itself needs a new object too, not just the array. This variant is also broken:
this.state.todos[0].done = true // object mutated in place
this.state.todos = [...this.state.todos] // new array, same object reference
The render will happen — there is a write to todos. But the keyed list compares rows by the identity of the data item: if the reference hasn’t changed and nothing else in state changed this cycle, the row is considered current and skipped. An object mutated in place keeps its old reference — its row won’t repaint. That’s why the .map above returns { ...i, done: true }: the new reference is precisely the signal that this row changed.
2. Writing to nested properties
Broken:
this.state.user.name = "Ada"; // ← invisible
this.state.filters.query = "x"; // ← invisible
Why it breaks: the proxy is one level deep by construction. state.user returns a plain object — no wrapper, no traps. The write user.name = 'Ada' goes straight into that object, past the proxy on state, and Micra never learns about it. Wrapping all the way down would mean creating another proxy on every read of a nested object, with the extra code and edge cases that come with it. Micra takes a different trade: reactivity one level deep, plus a convention — when you change something nested, replace the top-level key.
The fix: replace the top-level key with a fresh spread.
this.state.user = { ...this.state.user, name: "Ada" };
this.state.filters = { ...this.state.filters, query: "x" };
The spread here is not a deep copy, and that’s fine: a new reference is only needed along the path from the changed field up to the top-level key.
Since 2.5.1 the same thing has a shorter spelling — this.set accepts a dot-separated path:
this.set("user.name", "Ada");
this.set("filters.query", "x");
Internally it does exactly the same thing: each level of the path is rebuilt into a new object, and the top-level key is replaced with a single write — one trap hit, one render. This is sugar over the shallow model, not deep reactivity. The same mechanism powers data-model="filters.query" in markup.
3. Storing derived values in state
Broken:
state: {
todos: [],
totalCount: 0, // ← derived from todos.length
hasDone: false, // ← derived from todos.some(t => t.done)
filteredCount: 0, // ← derived
},
updateCounts() {
this.state.totalCount = this.state.todos.length
this.state.hasDone = this.state.todos.some(t => t.done)
this.state.filteredCount = this.filtered().length
}
Where this pattern comes from: the habit of “the template shows it, so it must be a state field”. In Micra, state holds source data only — the single source of truth. Anything computed from it — counts, flags, filtered subsets — isn’t data anymore; it’s a function of the data.
The broken version keeps two copies of one fact: todos and totalCount. Now you have to remember to call updateCounts() after every mutation — and mutations accumulate over time: adding, removing, toggling, loading from the server. Sooner or later one call gets forgotten, and the derived value drifts from its source. The worst part is when you notice: not at the moment of the mistake but later, as “the counter is sometimes wrong”, with no clear way to reproduce it.
The fix: compute in methods instead of storing in state.
state: { todos: [] },
totalCount() { return this.state.todos.length },
hasDone() { return this.state.todos.some(t => t.done) },
filtered() { return this.state.todos.filter(t => t.priority > 0) },
Methods are called straight from directives:
<span data-text="totalCount()"></span>
<button data-if="hasDone()">Clear completed</button>
About the cost of recomputing. The totalCount() expression is parsed into an AST once and cached; on each render only the method itself runs again — an ordinary pass over an array that’s already sitting in memory. And renders are batched: however many state writes happen inside one method, the recomputation happens once. Methods are cheap; drift is expensive.
One restriction: a method called from a directive must only read. Writing to state from inside an expression is an attempt to schedule a render during a render; since 2.7.1 such a write is kept, but no follow-up render is scheduled from it, and a warning lands in the console. If a method needs to write something, that’s an action — call it from an event handler.
4. getElementById + innerHTML for lists
Broken:
renderTodos() {
const html = this.state.todos
.map(t => `<li>${t.text}</li>`)
.join("")
document.getElementById("list").innerHTML = html
}
Why it breaks: this is hand-rolled list rendering with strings. The HTML is rebuilt and inserted wholesale, which means every existing node is destroyed and new ones created. Everything that lived on those nodes goes with them: focus and cursor position in an input, text selection, CSS transitions, scroll position inside a container. Handlers have to be re-attached after every rebuild. And ${t.text} inside innerHTML is a ready-made XSS hole if the text came from anywhere outside your own code.
The fix: <template data-each> + data-key.
<ul>
<template data-each="todos" data-key="id">
<li data-text="item.text"></li>
</template>
</ul>
What happens inside. <template> is inert — the browser doesn’t render its contents; it’s a blueprint for a row. Next to it, Micra keeps a comment node as the insertion point. For each key from data-key the row is cloned once: the markup is scanned, handlers are bound — and from then on that node is reused. On update, Micra diffs the list by key: rows whose items didn’t change are skipped, reordered rows are moved with the minimum number of operations, removed rows leave the DOM along with their listeners. Only what is genuinely new gets created — which is why focus in a field inside a row survives filtering the list.
data-key is not decoration. Without it the diff is positional: the i-th DOM row corresponds to the i-th array item. For a list that only grows this is enough, but delete from the middle and row identity slides — row state sticks to the position rather than the data. The key makes the row-to-item correspondence explicit; any stable field works, usually id.
Two more things that don’t work inside a row: a data-model bound to the row item, and data-ref. Neither is row-aware — data-model="item.qty" has no idea whose item that is, and data-ref isn’t collected from rows; since 2.7.1 Micra warns about both in the console. Instead, bind the value with data-bind and write through a handler that takes arguments:
<template data-each="items" data-key="id">
<li>
<span data-text="item.name"></span>
<input
data-bind="value:item.qty"
@input="setQty(item.id, $event.target.value)"
/>
</li>
</template>
setQty(id, qty) {
this.state.items = this.state.items.map((i) =>
i.id === id ? { ...i, qty: Number(qty) } : i,
);
}
5. addEventListener inside a method
Broken:
onCreate() {
this.refs.input.addEventListener("blur", () => this.validate())
}
Why it breaks: Micra keeps track only of listeners declared in markup. Everything attached via @event, data-on and data-model is recorded on the instance and removed in destroy(). A listener from addEventListener never enters that ledger — as far as Micra is concerned, it doesn’t exist.
From there, the usual scenario. The component gets destroyed manually via destroy(): Micra removes its own listeners and restores the DOM to its original shape, but it doesn’t take the markup off the page. The input stays in the document, and so does the manual blur listener on it: it keeps firing and calls a method on a dead component. If that same DOM later gets mounted again, there are now two listeners, and validate() starts running twice. In the second scenario — htmx or Turbo swapped out that chunk of the page — the old DOM leaves the document together with the input, so the listener never fires again. But the instance doesn’t go away by itself: Micra keeps every mounted component in its registry until destroy() is called, and nothing calls it on a swap. Turn on Micra.autoCleanup() once (since 2.7.0) — it notices component roots leaving the document and destroys them, onDestroy included. Without it, or an explicit Micra.destroy() on swap-out, swapped-out components pile up in memory whether they have manual listeners or not.
The fix: use @event or data-on in markup.
<input data-ref="input" @blur="validate" />
The same blur, but declared where Micra can see it: bound once, removed together with the component.
The one legitimate exception is document-level listeners (say, a click outside a dropdown). That event happens outside the component’s DOM, so there’s nowhere in the markup to declare it. Those get added in onCreate and removed in onDestroy:
onCreate() {
this._outside = (e) => {
if (!this.$el.contains(e.target)) this.close()
}
document.addEventListener("click", this._outside)
},
onDestroy() {
document.removeEventListener("click", this._outside)
}
Note this._outside: the function reference is saved precisely so that removeEventListener has something to remove. An anonymous arrow passed straight into addEventListener is unreachable for removal — called again, it’s already a different function.
6. Calling this.renderList() after a mutation
Broken:
addTodo(text) {
this.state.todos = [...this.state.todos, { id: Date.now(), text }]
this.renderList() // ← Micra already renders
this.updateCounts() // ← methods recompute on read
this.refresh() // ← doesn't exist
}
Why it’s wrong: the render is already scheduled. Every state write queues a repaint as a microtask; further writes in the same tick add nothing — there is one render, and it sees the final state after the method returns. Which is why a method can safely change three keys in a row: still one repaint, and the DOM never sees the intermediate states.
Each of the three lines above is redundant in its own way. renderList() doesn’t exist. updateCounts() isn’t needed — after item 3 the derived values became methods and recompute on read. refresh() doesn’t exist either. The one real way to force a repaint is this.render(), synchronous; it’s kept for rare cases like “I need to measure the DOM right now”, but after a state write it does the work twice — once itself, once more when the already-queued microtask arrives.
The fix: trust the library.
addTodo(text) {
this.state.todos = [...this.state.todos, { id: Date.now(), text }]
this.save() // side effect — OK
}
Side effects — saving, requests, emit — are fine to do from methods: that’s working with data, not with rendering. If you’re reaching for refresh() / redraw() / update() — they don’t exist. A write to state is the only signal.
7. Branching on event.key when a modifier is enough
Broken:
<input @keydown="onKey" />
onKey(e) {
if (e.key === "Enter") this.submit()
if (e.key === "Escape") this.cancel()
}
Why it’s unnecessary: since 2.5.1 Micra understands key and system modifiers right on the event. The branching handler works, but it blurs the picture: the markup no longer shows which keys matter here, and the method does dispatch instead of an action. With modifiers, submit and cancel stay plain actions — easier to call from other places and to test on their own.
The fix: move the condition into the attribute.
<input @keydown.enter="submit" @keydown.escape="cancel" />
Keys: .enter .escape .tab .space .delete and the arrows. System: .ctrl .shift .alt .meta. Plus .prevent .stop .self. An unrecognized modifier is matched against event.key case-insensitively — @keydown.a fires on the letter A.
Modifiers apply left to right, and that’s not a formality. @keydown.enter.prevent means “if it’s Enter — cancel the default action and run the handler”: prevent is only reached after the key matches. Swap them — @keydown.prevent.enter — and preventDefault runs on every keystroke, including the ones where the handler never fires. Parsing event.key by hand still works — it’s just rarely needed now.
8. Timers and external listeners without cleanup
Broken:
onCreate() {
this.interval = setInterval(() => this.tick(), 1000)
}
// no onDestroy → the timer keeps firing forever
Why it breaks: nobody calls clearInterval for you. After the component is destroyed, the interval keeps firing every second. The arrow () => this.tick() holds this, and through it the state, the DOM references, the whole closure; the garbage collector can’t reclaim any of it until the page reloads. The component is dead but goes on both occupying memory and doing work.
The fix: for every setup in onCreate, a paired teardown in onDestroy.
onCreate() {
this.interval = setInterval(() => this.tick(), 1000)
this._handleVisibility = () => this.checkActive()
document.addEventListener("visibilitychange", this._handleVisibility)
},
onDestroy() {
clearInterval(this.interval)
document.removeEventListener("visibilitychange", this._handleVisibility)
}
The rule: if onCreate calls setInterval, setTimeout (with a long delay), addEventListener on document/window/Micra.on, or creates a MutationObserver — onDestroy has to undo it. The easiest way to read these two hooks is as one ledger: every “acquired” line in onCreate has a “released” line in onDestroy, and review comes down to matching the pairs.
Listeners from @event and data-on on the component’s own DOM clean themselves up. So do bus subscriptions made through this.on(...): Micra remembers them and unsubscribes in destroy(). But Micra.on(...) called directly isn’t tied to the instance — you remove it yourself, like everything external.
All of this assumes the component actually gets destroyed. On pages where htmx or Turbo swap markup, that means Micra.autoCleanup() is on, or you call Micra.destroy() on swap-out yourself; otherwise onDestroy never runs and the pairs above never get released.
Pre-flight checklist
Run your code through these:
- Every list is a
<template data-each>withdata-key. NogetElementById/innerHTMLfor lists. - No row-bound
data-model(item.*) ordata-refinsidedata-eachrows — values viadata-bind, writes via a handler with arguments. - No state field is the
.length/.filter(...).length/.some(...)of another field. All such values are methods. - No
addEventListenerinside a regular method (document-level inonCreate/onDestroyis fine). - No
this.render()/this.update()/this.refresh()calls after mutations. - No nested-path writes (
state.user.name = x). Replace the top-level key instead, or usethis.set. - Key handling goes through modifiers (
@keydown.enter) where they exist, not manualevent.keyparsing. - Every timer and external listener from
onCreatehas a paired cleanup inonDestroy. - On pages with htmx or Turbo swaps,
Micra.autoCleanup()is on (orMicra.destroy()runs on swap-out). -
Micra.start()is at the end of the script.
If you can’t tick every box, rewrite.
Full documentation and live examples are in the docs. If you’re bringing over code from another attribute-driven library, see Porting attribute-driven code to Micra.