Skip to content

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.

my-todo/
├── index.html
├── src/main.pjs
├── vite.config.js
└── package.json
<!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.pjs"></script>
</body>
</html>
signal items = [];
signal filter = "all";
derived visible = filter === "all"
? items
: items.filter(t => (filter === "done" ? t.done : !t.done));
derived 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));
});
  • signal items = [] declares a reactive cell. items = newValue compiles to items.set(newValue); reading items inside a tracked context (an effect, a derived, a when predicate, or another signal’s RHS) compiles to items.get().
  • derived visible = ... and derived openCount = ... are read-only signals computed from items / filter. They recompute lazily when a dep changes. (You can write signal NAME = … instead and the transpiler auto-promotes when the RHS reads other signals — but derived makes the read-only intent explicit at the declaration site.)
  • Three reactive shapes, picked by what the body does. effect { … } is the multi-statement form (the list-render block tracks visible and re-runs the whole template). A ~> B is the single-sink form (the three lines at the bottom each bind one expression to one DOM property — keeping them per-line is what makes count.textContent re-fire only on openCount changes, not on unrelated items.length reads). when EXPR { … } (paired with when stop { … }) is the edge-triggered form for transitions — playing a sound, sending analytics, anything that should fire on the change rather than every render.
  • The $ shorthand binds document.querySelector once.

No Para-specific config needed. The transpiler emits standard import "@para/signals" statements that Vite resolves through node_modules:

import { defineConfig } from "vite";
export default defineConfig({});
{
"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": "*" }
}
Terminal window
parabun install
parabun run build

Output is a static dist/ directory. Deploy to any static host (Cloudflare Pages, Vercel, S3, GitHub Pages, etc.).

If you prefer types, swap src/main.pjs for src/main.pts, type the signals (signal items: Todo[] = []), and update index.html and package.json to point at .pts. Same compiled output either way.