Recipe: Todo app with localStorage, filters, and computed values

A complete, idiomatic Micra.js todo — the canonical answer to “build me a todo app.” Copy it and adjust. It leans on the patterns that make Micra worth using: keyed lists through data-each, derived values as methods (never stored in state), and @event handlers instead of manual addEventListener.

CDN: the script loads from cdn.jsdelivr.net, which mirrors npm and is allowed by strict Content-Security-Policy setups (where unpkg.com often isn’t).

What you get

Full source

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Todo</title>
    <style>
      body {
        font-family: system-ui, sans-serif;
        max-width: 480px;
        margin: 40px auto;
      }
      .row {
        display: flex;
        gap: 8px;
        margin-bottom: 12px;
      }
      .row input {
        flex: 1;
        padding: 8px;
      }
      .filters {
        display: flex;
        gap: 4px;
        margin-bottom: 12px;
      }
      .filters button.active {
        font-weight: bold;
      }
      .item {
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 6px 0;
        border-bottom: 1px solid #eee;
      }
      .item.done .text {
        text-decoration: line-through;
        opacity: 0.5;
      }
      .item button {
        margin-left: auto;
      }
    </style>
  </head>
  <body>
    <div data-component="todo-app">
      <h1>Todo <small data-text="counterLabel()"></small></h1>

      <div class="filters">
        <button data-class="active:filter==='all'" @click="setFilter('all')">
          All
        </button>
        <button
          data-class="active:filter==='active'"
          @click="setFilter('active')"
        >
          Active
        </button>
        <button data-class="active:filter==='done'" @click="setFilter('done')">
          Done
        </button>
      </div>

      <div class="row">
        <input
          data-model="newTask"
          placeholder="New task..."
          @keydown="onKey"
          maxlength="200"
        />
        <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="toggle"
          />
          <span class="text" 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, not 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 (called after each mutation) ──────────────
        save() {
          localStorage.setItem("todos", JSON.stringify(this.state.todos));
        },
        nextId() {
          return (
            Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
          );
        },

        // ── id extraction from event ──────────────────────────────
        itemId(e) {
          return e.currentTarget.closest("[data-id]").dataset.id;
        },

        // ── actions: mutate state, Micra re-renders automatically ─
        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();
        },
        toggle(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();
        },
        setFilter(f) {
          this.state.filter = f;
        },
        onKey(e) {
          if (e.key === "Enter") this.addTask();
        },
      });
      Micra.start();
    </script>
  </body>
</html>

Why this code (and not the obvious alternative)

Coming from jQuery or vanilla JS, the reflex is to reach for:

// ❌ Don't — the jQuery/vanilla reflex
document.getElementById('todo-list').innerHTML = items.map(...).join('')
el.addEventListener('click', () => this.toggle(id))
state: { totalCount: 0, hasDone: false }  // synced manually
addTask() { ...; this.renderList(); this.updateComputeds() }

That code works, but it bypasses every reason to use Micra. Side-by-side comparison:

What❌ Anti-pattern✅ Idiomatic Micra
List renderinggetElementById + innerHTML<template data-each>
Derived countsstate field synced in updateComputeds()counterLabel() method called from data-text
Item click handlersel.addEventListener('click', ...) in createItemEl@click="toggle"
After mutationthis.renderList() + this.save() + this.updateComputeds()Just mutate state; save() only
Item id lookupClosure over todo.id in handlerdata-bind="data-id:item.id" + e.currentTarget.closest('[data-id]')
AnimationsRe-fire on every state change (innerHTML=”)Fire only for new keyed rows
Memory after destroy()Listeners leak (not tracked by Micra)Listeners removed automatically

Patterns to reuse

These 5 patterns recur in every non-trivial Micra app — internalize them once:

1. Derived values are methods, not state

state: { todos: [] }                   // single source of truth
counterLabel() { return ... }          // computed from state
// <span data-text="counterLabel()"></span>

Never write:

state: { todos: [], counterLabel: '' }  // ❌ two states can diverge

2. Lists go through data-each, always

<template data-each="filtered()" data-key="id">
  <div data-text="item.name"></div>
</template>

The expression in data-each can call a method like filtered(). The expression in inner directives uses item (the per-row value) and index / $index.

3. Get item id inside a row handler via closest

Micra doesn’t pass item as an argument to @click. Either bind data-id:

<div data-bind="data-id:item.id">
  <button @click="remove">×</button>
</div>
remove(e) {
  const id = e.currentTarget.closest('[data-id]').dataset.id
  this.state.items = this.state.items.filter(x => x.id !== id)
}

Or — if you need the full object — capture it via the bus:

<button @click="open" data-bind="data-id:item.id">Open</button>
open(e) {
  const id = e.currentTarget.dataset.id
  const item = this.state.items.find(x => x.id === id)
  this.emit('item:open', item)
}

4. After a mutation: only do side effects, not rendering

addTask() {
  this.state.todos = [...this.state.todos, x]   // triggers re-render
  this.save()                                    // side effect, OK
  // ❌ DO NOT: this.renderList() / this.update()
}

Side effects: localStorage, fetch, console.log, analytics. NOT: renderList(), updateComputeds().

5. Keys must be stable and unique

nextId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6) }

If you use array index as key (data-key="$index") — you lose the entire benefit of keyed diff. Pick a real id.

What to avoid

If you find yourself writing any of these, stop and rewrite using the patterns above — they work, but defeat the point of the library: