TinyJoin logoTinyJoin

Todo starter

A useful first TinyJoin app needs one table, one read query, and small parameterized mutations. The demo above is running on this page, in an iframe, against a real in-memory database.

First, since the demo runs in a browser, register an import alias for TinyJoin. The site serves its own copy, because a Worker cannot be constructed from a cross-origin script:

<script type="importmap">
  {"imports": {"tinyjoin": "/lib/index.js"}}
</script>

Start with the markup the app will fill in:

<main>
  <form id="add">
    <input id="text" placeholder="What needs to be done?" autocomplete="off" />
    <button type="submit">Add</button>
  </form>
  <ul id="todos"></ul>
</main>

Opening the database is one call. This demo is intentionally memory-only: memory:// starts a fresh database on every reload, including the two sample todos added below. For an app that keeps todos between visits, use the persistent Vite starter with npm create tinyjoin@latest. Its schema setup and sample data are designed for reopening an existing database.

import {create} from 'tinyjoin';

const db = await create('memory://');

One table is enough, and creating it is ordinary SQL:

await db.exec(`
  CREATE TABLE todos (
    id TEXT PRIMARY KEY,
    text TEXT NOT NULL,
    done BOOLEAN NOT NULL DEFAULT false
  )
`);

Every write is parameterized, so application values never reach the SQL text. Row identifiers come from the browser, which avoids needing a sequence:

const addTodo = (text) =>
  db.query('INSERT INTO todos (id, text) VALUES ($1, $2)', [
    crypto.randomUUID(),
    text,
  ]);

const setDone = (id, done) =>
  db.query('UPDATE todos SET done = $1 WHERE id = $2', [done, id]);

const deleteTodo = (id) => db.query('DELETE FROM todos WHERE id = $1', [id]);

Reading is one ordinary SELECT, and the result has the familiar rows shape:

const listTodos = async () =>
  (await db.query('SELECT id, text, done FROM todos ORDER BY text, id')).rows;

The UI can stay equally ordinary. render replaces the visible list, each checkbox calls setDone, and each button calls deleteTodo:

const list = document.getElementById('todos');

const render = async () => {
  const todos = await listTodos();
  list.replaceChildren(
    ...todos.map(({id, text, done}) => {
      const item = document.createElement('li');
      item.className = done ? 'done' : '';

      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.checked = done;
      checkbox.onchange = () => setDone(id, checkbox.checked);

      const label = document.createElement('span');
      label.textContent = text;

      const remove = document.createElement('button');
      remove.textContent = 'Delete';
      remove.onclick = () => deleteTodo(id);

      item.append(checkbox, label, remove);
      return item;
    }),
  );
};

Rather than re-rendering by hand after every mutation, subscribe() reports which tables changed so the app can simply re-query:

db.subscribe({tables: ['todos']}, render);

The form only has to write. The subscription takes care of the rest:

document.getElementById('add').onsubmit = async (event) => {
  event.preventDefault();
  const input = document.getElementById('text');
  const text = input.value.trim();
  if (text !== '') {
    input.value = '';
    await addTodo(text);
  }
  input.focus();
};

Two rows to start with, and the app is complete:

await addTodo('Learn TinyJoin');
await addTodo('Build an app');

Add a little styling, and we're done:

@font-face {
  font-family: Inter;
  src: url(/fonts/inter.woff2) format('woff2');
  font-display: swap;
}

@accentColor: #7c3aed;
@spacing: 0.5rem;
@border: 1px solid #ccc;

body {
  font-family: Inter, sans-serif;
  letter-spacing: -0.04rem;
  margin: 0;
  min-height: 100vh;
  padding: @spacing * 2;

  * {
    box-sizing: border-box;
    outline-color: @accentColor;
  }
}

#add {
  display: flex;
  gap: @spacing;
}

#text {
  border: @border;
  flex: 1;
  font: inherit;
  letter-spacing: inherit;
  min-width: 0;
  padding: @spacing;
}

button {
  background: #fff;
  border: @border;
  cursor: pointer;
  font: inherit;
  letter-spacing: inherit;
  padding: @spacing;
}

#add button {
  background: @accentColor;
  border-color: @accentColor;
  color: #fff;
  padding: @spacing @spacing * 2;
}

#todos {
  list-style: none;
  margin: @spacing * 2 0 0;
  padding: 0;

  li {
    align-items: center;
    background: #fff;
    border: @border;
    display: flex;
    gap: @spacing;
    margin-bottom: @spacing;
    padding: @spacing;

    span {
      flex: 1;
      overflow: hidden;
      text-overflow: ellipsis;
      user-select: none;
      white-space: nowrap;
    }

    input[type='checkbox'] {
      accent-color: @accentColor;
      height: 1.1rem;
      width: 1.1rem;
    }

    &.done span {
      color: #ccc;
      text-decoration: line-through;
    }
  }
}