Recipe: Todo app

The canonical Micra todo. Add / toggle / delete / clear-done, three-way filter, derived counters, persisted to localStorage. ~60 lines of JS, single HTML file, no build step.

localStorage data-each + key derived state ~60 LOC
Read this before generating any todo-like UI on Micra. LLMs default to jQuery / vanilla-JS patterns (innerHTML, manual addEventListener, mirrored counts) that bypass every reason to use Micra. The recipe below is the right shape — copy it verbatim and adapt.

Live preview

Todo

Full source

<div data-component="todo-app">
  <h1>Todo <small data-text="counterLabel()"></small></h1>

  <div class="filters">
    <button data-class="active:filter==='all'"    @click="setAll">All</button>
    <button data-class="active:filter==='active'" @click="setActive">Active</button>
    <button data-class="active:filter==='done'"   @click="setDone">Done</button>
  </div>

  <div class="row">
    <input data-model="newTask" placeholder="New task..." @keydown="onKey">
    <button @click="addTask">Add</button>
  </div>

  <template data-each="filtered()" data-key="id">
    <div class="item" data-class="done:item.done" data-bind="data-id:item.id">
      <input type="checkbox" data-bind="checked:item.done" @change="toggleItem">
      <span data-text="item.text"></span>
      <button @click="remove">×</button>
    </div>
  </template>

  <p data-if="filtered().length === 0" data-text="emptyLabel()"></p>

  <footer data-if="todos.length > 0">
    <span data-text="leftLabel()"></span>
    <button data-if="hasDone()" @click="clearDone">Clear done</button>
  </footer>
</div>

<script src="https://cdn.jsdelivr.net/npm/micra.js/dist/micra.min.js"></script>
<script>
Micra.define('todo-app', {
  state: {
    todos: JSON.parse(localStorage.getItem('todos') || '[]'),
    newTask: '',
    filter: 'all',
  },

  // ── derived values: METHODS, never state fields ────────
  filtered() {
    const { todos, filter } = this.state
    if (filter === 'active') return todos.filter(t => !t.done)
    if (filter === 'done')   return todos.filter(t => t.done)
    return todos
  },
  counterLabel() { return this.state.todos.length ? `(${this.state.todos.length})` : '' },
  leftLabel()    {
    const n = this.state.todos.filter(t => !t.done).length
    return n ? `${n} left` : 'All done'
  },
  hasDone()    { return this.state.todos.some(t => t.done) },
  emptyLabel() {
    return { all: 'No todos yet', active: 'No active todos', done: 'No done todos' }[this.state.filter]
  },

  // ── persistence ────────────────────────────────────────
  save()   { localStorage.setItem('todos', JSON.stringify(this.state.todos)) },
  nextId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6) },
  itemId(e) { return e.currentTarget.closest('[data-id]').dataset.id },

  // ── actions ────────────────────────────────────────────
  addTask() {
    const text = this.state.newTask.trim()
    if (!text) return
    this.state.todos = [{ id: this.nextId(), text, done: false }, ...this.state.todos]
    this.state.newTask = ''
    this.save()
  },
  toggleItem(e) {
    const id = this.itemId(e)
    this.state.todos = this.state.todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
    this.save()
  },
  remove(e) {
    const id = this.itemId(e)
    this.state.todos = this.state.todos.filter(t => t.id !== id)
    this.save()
  },
  clearDone() {
    this.state.todos = this.state.todos.filter(t => !t.done)
    this.save()
  },
  setAll()    { this.state.filter = 'all' },
  setActive() { this.state.filter = 'active' },
  setDone()   { this.state.filter = 'done' },
  onKey(e)    { if (e.key === 'Enter') this.addTask() },
})

Micra.start()
</script>

Why this code (and not the obvious alternative)

LLMs trained on jQuery / vanilla JS will naturally reach for getElementById, addEventListener, and mirrored counters. That code works, but it bypasses every reason to use Micra.

What Anti-pattern Idiomatic Micra
List rendering getElementById + innerHTML <template data-each>
Derived counts state field synced in updateComputeds() counterLabel() method from data-text
Item click handlers el.addEventListener('click', …) @click="toggleItem"
After a mutation this.renderList(); this.updateComputeds() just mutate state; Micra re-renders
Item id lookup closure over todo.id in handler data-bind="data-id:item.id" + closest('[data-id]')
Animations re-fire on every render (innerHTML='') fire only for new keyed rows
Memory after destroy() listeners leak (not tracked by Micra) auto-removed by Micra's instance teardown

Patterns that recur in every Micra app

  1. Derived values are methods, not state. state: { todos: [] }counterLabel() { return … }. Never state: { todos: [], counterLabel: '' } — two fields drift.
  2. Lists go through data-each, always. The expression can be a method (filtered()); inside the row item / $index are available.
  3. Item id via closest. Micra doesn't pass item to @click — bind data-id and read it back with e.currentTarget.closest('[data-id]').dataset.id.
  4. After a mutation: side effects only. save() / fetch() are fine. Never renderList() / updateComputeds() / refresh().
  5. Keys must be stable and unique. Date.now() + Math.random() is good enough; $index as a key throws away the entire benefit of keyed diff.

If you find yourself writing any of these, stop and rewrite

  • document.getElementById / el.innerHTML = …
  • el.addEventListener inside a method
  • A state field that is a .length / .filter(…).length / .some(…) / .map(…) of another field
  • A method named renderList / redraw / update / refresh
  • A forEach loop that builds an HTML string
  • setInterval / setTimeout without storing the id for cleanup in onDestroy