Frontend example
A todo list using Para against the vanilla DOM, built with Vite. Demonstrates signal, derived signals, effect { }, the ~> reactive-binding operator, and the bare-read sugar.
You can write this in either .pts (TypeScript superset) or .pjs (JavaScript superset). Both compile to identical output. The tabs below sync — pick one and the rest of the page follows.
Project layout
Section titled “Project layout”my-todo/├── index.html├── src/main.pts├── vite.config.ts└── package.jsonmy-todo/├── index.html├── src/main.pjs├── vite.config.js└── package.jsonindex.html
Section titled “index.html”<!doctype html><html> <body> <input id="new" placeholder="new todo…" autofocus /> <button id="add">add</button> <select id="filter"> <option value="all">all</option> <option value="open">open</option> <option value="done">done</option> </select> <ul id="list"></ul> <p id="count"></p> <p id="toast" hidden>all done</p> <script type="module" src="./src/main.pts"></script> </body></html>Source
Section titled “Source”type Todo = { id: number; text: string; done: boolean };
signal items: Todo[] = [];signal filter: "all" | "open" | "done" = "all";
signal visible = filter === "all" ? items : items.filter(t => (filter === "done" ? t.done : !t.done));signal openCount = items.filter(t => !t.done).length;
let nextId = 1;
const $ = <T extends Element>(s: string) => document.querySelector<T>(s)!;const input = $<HTMLInputElement>("#new");const addBtn = $<HTMLButtonElement>("#add");const filterEl = $<HTMLSelectElement>("#filter");const list = $<HTMLUListElement>("#list");const count = $<HTMLParagraphElement>("#count");const toast = $<HTMLParagraphElement>("#toast");
addBtn.addEventListener("click", () => { if (!input.value.trim()) return; items = [...items, { id: nextId++, text: input.value.trim(), done: false }]; input.value = "";});
filterEl.addEventListener("change", () => { filter = filterEl.value as typeof filter;});
// Effect for the list — long body, multiple template lines. Effect// blocks are the right shape when the body isn't a single sink.effect { list.innerHTML = visible .map(t => ` <li data-id="${t.id}"> <input type="checkbox" ${t.done ? "checked" : ""} /> <span class="${t.done ? "done" : ""}">${t.text}</span> </li> `).join("");}
// Single-sink reactive bindings. Each `A ~> B` desugars to// effect(() => { B = A; }). One per line keeps the dep set tight —// the textContent binding only re-fires on openCount changes.`${openCount} open` ~> count.textContent;!openCount && !items.length ~> count.hidden;!items.length || openCount > 0 ~> toast.hidden;
list.addEventListener("click", e => { if (!(e.target instanceof HTMLInputElement)) return; const li = e.target.closest<HTMLLIElement>("li[data-id]"); if (!li) return; const id = +li.dataset.id!; items = items.map(t => (t.id === id ? { ...t, done: !t.done } : t));});signal items = [];signal filter = "all";
signal visible = filter === "all" ? items : items.filter(t => (filter === "done" ? t.done : !t.done));signal openCount = items.filter(t => !t.done).length;
let nextId = 1;
const $ = document.querySelector.bind(document);const input = $("#new");const addBtn = $("#add");const filterEl = $("#filter");const list = $("#list");const count = $("#count");const toast = $("#toast");
addBtn.addEventListener("click", () => { if (!input.value.trim()) return; items = [...items, { id: nextId++, text: input.value.trim(), done: false }]; input.value = "";});
filterEl.addEventListener("change", () => { filter = filterEl.value;});
// Effect for the list — long body, multiple template lines. Effect// blocks are the right shape when the body isn't a single sink.effect { list.innerHTML = visible .map(t => ` <li data-id="${t.id}"> <input type="checkbox" ${t.done ? "checked" : ""} /> <span class="${t.done ? "done" : ""}">${t.text}</span> </li> `).join("");}
// Single-sink reactive bindings. Each `A ~> B` desugars to// effect(() => { B = A; }). One per line keeps the dep set tight —// the textContent binding only re-fires on openCount changes.`${openCount} open` ~> count.textContent;!openCount && !items.length ~> count.hidden;!items.length || openCount > 0 ~> toast.hidden;
list.addEventListener("click", e => { if (e.target.tagName !== "INPUT") return; const li = e.target.closest("li[data-id]"); if (!li) return; const id = +li.dataset.id; items = items.map(t => (t.id === id ? { ...t, done: !t.done } : t));});Notes on the source
Section titled “Notes on the source”signal items = []declares a reactive cell.items = newValuecompiles toitems.set(newValue); readingitemsinside a tracked context (aneffect, aderived, awhenpredicate, or another signal’s RHS) compiles toitems.get().signal visible = ...andsignal openCount = ...are auto-promoted toderived()because their initializers read other signals. They recompute lazily when a dep changes.- The list
effect { }tracksvisibleand re-runs when it changes. Effect blocks are the right shape when the body is multi-line or has more than one statement; for single-sink value bindings, the~>operator is more direct. A ~> Bis the reactive-binding operator — desugars toeffect(() => { B = A; }). Three of them at the bottom of the file:count.textContent,count.hidden,toast.hiddeneach track their own expression. One per line keeps the dep set tight:count.textContentonly re-runs onopenCountchanges, not on the unrelateditems.lengthreads. For side effects that should fire only on transitions (playing a sound, sending analytics),when EXPR { … }(and the pairedwhen not { … }) are the edge-triggered alternative.- The
$shorthand bindsdocument.querySelectoronce. In.ptsit’s a small generic helper so each call site can pass its element type ($<HTMLInputElement>("#new")) and the result stays cast-free; in.pjsit’s justdocument.querySelector.bind(document)since there are no types to thread. - The
.ptsversion queries each element ref once at the top with its concrete type so handlers stay cast-free; the one remainingasisfilterEl.value as typeof filter(TS can’t narrow<select>.valueto a literal union without a runtime check). The.pjsversion skips all of that.
vite.config
Section titled “vite.config”import { defineConfig } from "vite";
export default defineConfig({ resolve: { alias: [{ find: /^para:(.*)$/, replacement: "@para/$1" }], },});import { defineConfig } from "vite";
export default defineConfig({ resolve: { alias: [{ find: /^para:(.*)$/, replacement: "@para/$1" }], },});package.json
Section titled “package.json”{ "name": "my-todo", "type": "module", "scripts": { "build": "parabun build src/main.pts --outfile dist/main.js && vite build", "dev": "parabun build src/main.pts --watch --outfile dist/main.js & vite" }, "dependencies": { "@para/signals": "*", "@para/parallel": "*" }, "devDependencies": { "vite": "^5", "bun-types": "*" }}{ "name": "my-todo", "type": "module", "scripts": { "build": "parabun build src/main.pjs --outfile dist/main.js && vite build", "dev": "parabun build src/main.pjs --watch --outfile dist/main.js & vite" }, "dependencies": { "@para/signals": "*", "@para/parallel": "*" }}Build and deploy
Section titled “Build and deploy”parabun installparabun run buildOutput is a static dist/ directory. Deploy to any static host (Cloudflare Pages, Vercel, S3, GitHub Pages, etc.).