Part 1: Build a Todo App#
Build a working full-stack todo app in a single file. No frameworks, no boilerplate -- just Jac.
Prerequisites: Installation complete, Hello World done.
Create the Project#
--skip skips the interactive prompts after creation. You don't need to run jac install separately -- jac start handles dependency installation automatically.
Now replace main.jac with the code below and create styles.css in your project root:
.container { max-width: 500px; margin: 40px auto; font-family: system-ui; padding: 20px; }
.input-row { display: flex; gap: 8px; margin-bottom: 20px; }
.input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem; }
.btn-add { padding: 10px 20px; background: #4CAF50; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
.todo-item { display: flex; align-items: center; padding: 10px; border-bottom: 1px solid #eee; gap: 10px; }
.todo-title { flex: 1; }
.todo-done { text-decoration: line-through; color: #888; }
.btn-delete { background: #e53e3e; color: white; border: none; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
.count { text-align: center; color: #888; margin-top: 16px; font-size: 0.9rem; }
We'll walk through each piece of main.jac below.
Define Your Data#
import from uuid { uuid4 }
cl import "./styles.css";
# A node becomes a persistent data container in the graph when attached to a root node
node Todo {
has id: str,
title: str,
done: bool = False;
}
A node is a data type that can live in Jac's built-in graph database. Unlike a regular class, nodes can persist across server restarts (when attached to the global root) -- no external database setup needed. has declares the node's properties with types and optional defaults.
Two imports: uuid is a standard Python library (Jac can import any Python package), and cl import is a client-side import that loads CSS in the browser.
Create Server Endpoints#
"""Add a todo and return it."""
def:pub add_todo(title: str) -> dict {
todo = root ++> Todo(id=str(uuid4()), title=title);
return {"id": todo[0].id, "title": todo[0].title, "done": todo[0].done};
}
"""Get all todos."""
def:pub get_todos -> list {
return [{"id": t.id, "title": t.title, "done": t.done} for t in [root-->](?:Todo)];
}
"""Toggle a todo's done status."""
def:pub toggle_todo(id: str) -> dict {
for todo in [root-->](?:Todo) {
if todo.id == id {
todo.done = not todo.done;
return {"id": todo.id, "title": todo.title, "done": todo.done};
}
}
return {};
}
"""Delete a todo."""
def:pub delete_todo(id: str) -> dict {
for todo in [root-->](?:Todo) {
if todo.id == id {
del todo;
return {"deleted": id};
}
}
return {};
}
There's a lot of new syntax here. Let's unpack it:
def:pub marks a function as public. Jac automatically generates an HTTP endpoint for every def:pub function -- you don't write routes, controllers, or serializers. The function is the API.
root ++> Todo(...) creates a new Todo node and connects it to root with an edge. root is the graph's built-in entry point -- think of it as the top of your data tree. The ++> operator returns a list, so todo[0] grabs the newly created node.
[root-->](?:Todo) reads as "all nodes connected from root that are Todo nodes." It's a graph query -- the (?:Type) syntax filters by node type.
Your data ends up looking like this:
Build the Frontend#
cl def:pub app -> JsxElement {
has items: list = [],
text: str = "";
async can with entry {
items = await get_todos();
}
async def add -> None {
if text.strip() {
todo = await add_todo(text.strip());
items = items.concat([todo]);
text = "";
}
}
async def toggle(id: str) -> None {
await toggle_todo(id);
items = items.map(
lambda t: any -> any {
return {"id": t.id, "title": t.title, "done": not t.done}
if t.id == id else t;
}
);
}
async def remove(id: str) -> None {
await delete_todo(id);
items = items.filter(lambda t: any -> bool { return t.id != id; });
}
remaining = len(items.filter(lambda t: any -> bool { return not t.done; }));
return
<div class="container">
<h1>Todo App</h1>
<div class="input-row">
<input
class="input"
value={text}
onChange={lambda e: any -> None { text = e.target.value; }}
onKeyPress={lambda e: any -> None {
if e.key == "Enter" { add(); }
}}
placeholder="What needs to be done?"
/>
<button class="btn-add" onClick={add}>Add</button>
</div>
{[
<div key={t.id} class="todo-item">
<input
type="checkbox"
checked={t.done}
onChange={lambda -> None { toggle(t.id); }}
/>
<span class={"todo-title " + ("todo-done" if t.done else "")}>
{t.title}
</span>
<button
class="btn-delete"
onClick={lambda -> None { remove(t.id); }}
>
X
</button>
</div> for t in items
]}
<div class="count">{remaining} items remaining</div>
</div>;
}
The cl prefix means this code runs in the browser, not on the server. def:pub app declares the main component that Jac renders.
has items: list = [] declares reactive state. When items changes, the UI re-renders -- same idea as React's useState, but declared as properties instead of function calls.
can with entry is a lifecycle ability that runs when the component mounts. It fetches todos from the server on first load.
The key thing to notice: await add_todo(text) calls the server function as if it were local. Because add_todo is def:pub, Jac generated an HTTP endpoint on the server and a matching client stub automatically. You never think about HTTP.
The rest is JSX-like syntax: {[... for t in items]} renders a list, lambda handles events, and {expression} embeds values.
Run It#
Complete main.jac for copy-paste
import from uuid { uuid4 }
cl import "./styles.css";
node Todo {
has id: str,
title: str,
done: bool = False;
}
"""Add a todo and return it."""
def:pub add_todo(title: str) -> dict {
todo = root ++> Todo(id=str(uuid4()), title=title);
return {"id": todo[0].id, "title": todo[0].title, "done": todo[0].done};
}
"""Get all todos."""
def:pub get_todos -> list {
return [{"id": t.id, "title": t.title, "done": t.done} for t in [root-->](?:Todo)];
}
"""Toggle a todo's done status."""
def:pub toggle_todo(id: str) -> dict {
for todo in [root-->](?:Todo) {
if todo.id == id {
todo.done = not todo.done;
return {"id": todo.id, "title": todo.title, "done": todo.done};
}
}
return {};
}
"""Delete a todo."""
def:pub delete_todo(id: str) -> dict {
for todo in [root-->](?:Todo) {
if todo.id == id {
del todo;
return {"deleted": id};
}
}
return {};
}
cl def:pub app -> JsxElement {
has items: list = [],
text: str = "";
async can with entry {
items = await get_todos();
}
async def add -> None {
if text.strip() {
todo = await add_todo(text.strip());
items = items.concat([todo]);
text = "";
}
}
async def toggle(id: str) -> None {
await toggle_todo(id);
items = items.map(
lambda t: any -> any {
return {"id": t.id, "title": t.title, "done": not t.done}
if t.id == id else t;
}
);
}
async def remove(id: str) -> None {
await delete_todo(id);
items = items.filter(lambda t: any -> bool { return t.id != id; });
}
remaining = len(items.filter(lambda t: any -> bool { return not t.done; }));
return
<div class="container">
<h1>Todo App</h1>
<div class="input-row">
<input
class="input"
value={text}
onChange={lambda e: any -> None { text = e.target.value; }}
onKeyPress={lambda e: any -> None {
if e.key == "Enter" { add(); }
}}
placeholder="What needs to be done?"
/>
<button class="btn-add" onClick={add}>Add</button>
</div>
{[
<div key={t.id} class="todo-item">
<input
type="checkbox"
checked={t.done}
onChange={lambda -> None { toggle(t.id); }}
/>
<span class={"todo-title " + ("todo-done" if t.done else "")}>
{t.title}
</span>
<button
class="btn-delete"
onClick={lambda -> None { remove(t.id); }}
>
X
</button>
</div> for t in items
]}
<div class="count">{remaining} items remaining</div>
</div>;
}
This starts on port 8000 by default. Use jac start main.jac --port 3000 to pick a different port.
Common issue
If you see "Address already in use", another process is on port 8000. Use --port to pick a different port, or see Troubleshooting: Server won't start.
Open http://localhost:8000. You should see a clean todo app with an input field and an "Add" button. Try it:
- Type "Buy groceries" and press Enter -- the todo appears
- Click the checkbox -- it gets crossed out
- Click X -- it disappears
- Stop the server and restart it -- your todos are still there
That last point is important. The data persisted because nodes live in the graph database, not in memory.
What You Learned#
You built a full-stack app in a single file with no boilerplate. Here are the Jac concepts you used:
node-- persistent data types stored in the graphdef:pub-- functions that auto-become HTTP endpointsroot ++>-- create nodes and connect them to the graph[root-->](?:Todo)-- query nodes by typecl def:pub app-- client-side component that runs in the browserhas-- reactive state that triggers re-renderscan with entry-- lifecycle hook (runs on mount)await func()-- call server functions transparently from the client
Next Step#
Your todo app works, but every user shares the same data and there's nothing intelligent about it. In Part 2, you'll add AI-powered categorization with just a few lines of code.