← All posts

Common Mistakes — and How to Fix Them

Eight anti-patterns that account for ~95% of Micra bugs in the wild. Each one comes from the same root cause: trying to use Micra like jQuery or React, instead of the way it actually wants to be used.

If your code looks like any of these, stop and rewrite. The fix is usually shorter than the broken version.


1. Mutating arrays in place

Broken:

addTodo(text) {
  this.state.todos.push({ id: Date.now(), text })  // ← invisible to Micra
}

Why it breaks: Micra’s reactive proxy is shallow. It only sees writes to top-level keys of this.state. .push() mutates the array’s internals — the proxy never fires.

Fix: replace the top-level key.

addTodo(text) {
  this.state.todos = [...this.state.todos, { id: Date.now(), text }]
}

Same for .splice, .sort, .reverse, .pop — all in-place. Always produce a new array.

The three patterns you’ll write 100× a day:

// 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
)

2. Writing to nested properties

Broken:

this.state.user.name = "Ada"     // ← invisible
this.state.filters.query = "x"   // ← invisible

Why it breaks: same as #1 — only top-level keys trigger reactivity.

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" }

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
}

You then have to remember to call updateCounts() after every mutation. Sooner or later, you forget — and the derived value drifts from the source.

Fix: derive in methods, not 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 from directives:

<span data-text="totalCount()"></span>
<button data-if="hasDone()">Clear done</button>

Micra caches expression ASTs but re-runs methods on every render — that’s the point. Methods are cheap; drift is expensive.


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: you’ve reinvented React with strings. No keyed diffing, no event re-binding, no auto-cleanup. Every render re-creates every DOM node.

Fix: <template data-each> + data-key.

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

Micra computes the diff against the previous keyed list and only touches changed nodes.


5. addEventListener inside a method

Broken:

onCreate() {
  this.refs.input.addEventListener("blur", () => this.validate())
}

Why it breaks: the listener never gets cleaned up. When the component unmounts (e.g. via data-if), the handler stays attached and the closure pins the entire component in memory.

Fix: use @event or data-on in markup.

<input data-ref="input" @blur="validate" />

Micra tracks @event, data-on, data-model listeners on the instance and removes them in destroy().

The one legitimate exception: document-level listeners (e.g. outside-click). Those go in onCreate + onDestroy:

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

6. Calling this.renderList() after a mutation

Broken:

addTodo(text) {
  this.state.todos = [...this.state.todos, { text }]
  this.renderList()       // ← Micra already renders
  this.updateCounts()     // ← methods recompute on read
  this.refresh()          // ← no such concept
}

Why it’s wrong: Micra batches a microtask render on every state write. Side effects (like this.save()) are fine. Render calls are noise.

Fix: trust the framework.

addTodo(text) {
  this.state.todos = [...this.state.todos, { text }]
  this.save()  // side effect — OK
}

If you find yourself reaching for refresh() / redraw() / update() — there isn’t one. State writes are the only signal.


7. Branching on event.key when a modifier would do

Broken:

<input @keydown="onKey" />
onKey(e) {
  if (e.key === "Enter") this.submit()
  if (e.key === "Escape") this.cancel()
}

Why it’s more than you need: since 2.5, Micra supports key and system modifiers on events directly. The handler-with-a-switch is extra indirection, and the markup no longer tells you which keys matter.

Fix: put the guard in the attribute.

<input @keydown.enter="submit" @keydown.escape="cancel" />

Key guards: .enter .escape .tab .space and the arrows. System guards: .ctrl .shift .alt .meta. Plus .prevent .stop .self. They compose left to right — @keydown.enter.prevent means “if the key is Enter, then preventDefault, then run the handler,” not “always preventDefault.” (Branching on event.key by hand still works — it’s just rarely what you want now.)


8. Timers / external listeners without cleanup

Broken:

onCreate() {
  this.interval = setInterval(() => this.tick(), 1000)
}
// no onDestroy → timer keeps firing forever

Why it breaks: when the component unmounts, the interval keeps running. The closure keeps a reference to this, so the entire component (including its state, DOM refs, etc.) lives in memory until the page reload.

Fix: mirror every onCreate setup with onDestroy cleanup.

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)
}

Rule of thumb: if onCreate calls setInterval, setTimeout (long delay), addEventListener on document/window/Micra.on, or creates a MutationObserveronDestroy must tear it down.

@event and data-on listeners on the component’s own DOM are auto-cleaned. Anything external is your responsibility.


Pre-flight checklist

Before you ship, scan your code for these:

  • Every list is <template data-each> with data-key. No getElementById / innerHTML for lists.
  • No state field is .length / .filter(...).length / .some(...) of another state field. All such values are methods.
  • No addEventListener inside a non-lifecycle method (document-level listeners in onCreate/onDestroy are fine).
  • No this.render() / this.update() / this.refresh() calls after state mutations.
  • No nested-path writes (state.user.name = x). Replace top-level instead.
  • Key handling uses modifiers (@keydown.enter) where a guard exists, not manual event.key branching.
  • All timers / external listeners in onCreate have a matching cleanup in onDestroy.
  • Micra.start() is at the end of the script.

If you cannot tick every box, rewrite.

GitHub · Docs · Migrating from Alpine.js