Recipe: Todo list

The “hello world” of Civility apps — a single Collection is the whole data model. Assume the singleton app store, light-DOM routes, and a worker.ts from the README around this.

Create with set(id, value) (there’s no add), toggle with update, remove with delete.

// www/store/app.ts
import { Store } from '@civility/store'
import { IDBStorage } from '@civility/store/idb'

type Todo = { text: string; done: boolean; createdAt: string }

const store = new Store(new IDBStorage({ dbName: 'me.example.todo' }), {
  collections: ['todos'],
  versions: [{
    version: globalThis.__CIVILITY__?.version ?? '0.0.0',
    schema: { type: 'object', properties: { todos: { type: 'object' } } },
  }],
})

export class App {
  todos = store.collection<Todo>('todos')
  #subs = new Set<() => void>()
  constructor() {
    store.subscribe(() => this.#notify())
    this.#init()
  }
  async #init() {
    await this.todos.preload()
    this.#notify()
  }
  #notify() {
    for (const cb of this.#subs) cb()
  }
  addEventListener(fn: () => void) {
    this.#subs.add(fn)
  }
  removeEventListener(fn: () => void) {
    this.#subs.delete(fn)
  }

  add(text: string) {
    return this.todos.set(crypto.randomUUID(), {
      text,
      done: false,
      createdAt: new Date().toISOString(),
    })
  }
  toggle(id: string, done: boolean) {
    return this.todos.update(id, { done })
  }
  remove(id: string) {
    return this.todos.delete(id)
  }
}
export default new App()
// www/routes/home.ts
import { html, LitElement } from 'lit'
import app from '../store/app.ts'

export class HomePage extends LitElement {
  protected override createRenderRoot() {
    return this
  }
  #onUpdate = () => this.requestUpdate()
  override connectedCallback() {
    super.connectedCallback()
    app.addEventListener(this.#onUpdate)
  }
  override disconnectedCallback() {
    app.removeEventListener(this.#onUpdate)
    super.disconnectedCallback()
  }

  #submit(e: SubmitEvent) {
    e.preventDefault()
    const input = (e.target as HTMLFormElement).elements.namedItem(
      'text',
    ) as HTMLInputElement
    if (input.value.trim()) {
      app.add(input.value.trim())
      input.value = ''
    }
  }

  override render() {
    const todos = [...(app.todos.value ?? [])] // Map<string, Todo> → [id, todo][]
    return html`
      <form
        @submit=${this
          .#submit}><input name="text" placeholder="New todo" autofocus></form>
      <ul>${todos.map(([id, t]) =>
        html`
          <li>
            <input type="checkbox" .checked=${t.done} @change=${(e: Event) =>
              app.toggle(id, (e.target as HTMLInputElement).checked)}>
            <span style=${t.done ? 'text-decoration:line-through' : ''}>${t
              .text}</span>
            <button @click=${() => app.remove(id)}>×</button>
          </li>
        `
      )}</ul>
    `
  }
}
customElements.define('home-page', HomePage)

Add sync and two devices share the list; because each toggle is a CRDT change, editing different todos offline on each device merges cleanly.


Next: Journal adds a settings document alongside the collection.