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.
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
- Derived values are methods, not state. state: { todos: [] } → counterLabel() { return … }. Never state: { todos: [], counterLabel: '' } — two fields drift.
- Lists go through data-each, always. The expression can be a method (filtered()); inside the row item / $index are available.
- 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.
- After a mutation: side effects only. save() / fetch() are fine. Never renderList() / updateComputeds() / refresh().
- 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