Tutorial
Build a small reactive chat app, end to end, with a real React UI.
The Quickstart got one query and one mutation running and showed you reactivity in the dashboard. This tutorial goes further. You'll build a small chat app: multiple conversations, a real React UI, pagination, an action that calls out to the network, and live updates across two browser tabs. It's the full shape a real helipod app is built from.
Every step below is runnable. You'll need Bun for this one. The tutorial bundles its own UI
with bun build, so Bun is required here even though the backend engine itself also runs on Node.
What you'll build
- A
conversationstable and amessagestable, related by an indexed foreign key, withmessagesalso carrying a shard key (the scale-out annotation: free and inert on a single-node deployment, but the same code that would let this table's writes fan out across nodes later). - Queries that read a whole conversation (
.eqon an index) and page through one 20 messages at a time (.paginate). - Mutations that insert, replace, and delete (the three ways a mutation writes), each fanning out reactively to every open subscription that reads what it touched.
- An action that reaches out to the network, callable from the client with
useAction. - A React UI wired with
HelipodProvider,useQuery,useMutation(with an optimistic update for instant sends), anduseAction, plus one-shot manual pagination via the client directly. - Two browser tabs open side by side, proving the whole thing is reactive with zero refresh code.
Before you start
You need Bun, for bun build (bundling the web UI) and to run helipod dev itself. No
prior helipod project needed. This tutorial creates one from scratch.
First time through? Skim past the sharding commentary (.shardKey/shardBy notes in steps 2
and 4). It changes nothing about how this app behaves on one node, and the app works identically
if you ignore it. Come back to it with Scaling when you care about
multi-node.
Set up the project
mkdir chat-app && cd chat-app
bun init -y
bun add @helipod/cli @helipod/values @helipod/client react react-dom@helipod/cliis thehelipodcommand.@helipod/valuesgives youdefineSchema,defineTable, and thevvalidators.@helipod/clientis the browser client: the reactive connection, plus its/reactsubpath for hooks.
Define the schema
Your backend lives in helipod/ (the default function directory). Create helipod/schema.ts:
import { defineSchema, defineTable, v } from "@helipod/values";
export default defineSchema({
conversations: defineTable({
title: v.string(),
}),
messages: defineTable({
conversationId: v.id("conversations"),
author: v.string(),
body: v.string(),
edited: v.optional(v.boolean()),
})
.index("by_conversation", ["conversationId"])
.shardKey("conversationId"),
});Two tables:
conversations: just a title. Every table automatically gets a typed_idand a_creationTime, so this needs nothing else.messages: a typed reference to its conversation (v.id("conversations")), an author, a body, and an optionaleditedflag (v.optional(...), absent until a message is actually edited). See Schema & tables for the full validator catalog.
Two annotations on messages matter for everything that follows:
.index("by_conversation", ["conversationId"])lets a query ask for exactly one conversation's messages instead of scanning the whole table..shardKey("conversationId")marksconversationIdas this table's shard key, the scale-out seam from Scaling. On the single-node deployment this tutorial runs, it's inert metadata: it costs nothing and changes nothing about how the app behaves. It does mean every mutation that writesmessagesmust declare which shard (which conversation) it's writing to, which you'll see in the next step.
Conversations: create and list
Create helipod/conversations.ts:
import { v } from "@helipod/values";
import { query, mutation } from "./_generated/server";
export const create = mutation({
args: { title: v.string() },
returns: v.id("conversations"),
handler: (ctx, { title }) => ctx.db.insert("conversations", { title }),
});
export const list = query({
returns: v.array(
v.object({ _id: v.id("conversations"), _creationTime: v.number(), title: v.string() }),
),
handler: (ctx) => ctx.db.query("conversations", "by_creation").collect(),
});Nothing new here beyond the Quickstart: create inserts,
list reads every conversation via the built-in by_creation index (every table gets one, in
insertion order). The returns validator on both is what lets the client (and, later, an
optimistic update) know their exact result type. See
Queries.
Messages: insert, replace, delete
Create helipod/messages.ts. This is the file that does the real work: three mutations covering
every way a mutation writes, plus two queries:
import { v } from "@helipod/values";
import { query, mutation } from "./_generated/server";
const messageObject = v.object({
_id: v.id("messages"),
_creationTime: v.number(),
conversationId: v.id("conversations"),
author: v.string(),
body: v.string(),
edited: v.optional(v.boolean()),
});
// Insert. `shardBy: "conversationId"` routes this write to the shard that owns this
// conversation. That's required because `messages` declared `.shardKey("conversationId")` in
// the schema, and because `conversationId` is a required arg of the same type as the shard key
// field, codegen cross-checks the pairing at every `helipod dev`/`helipod codegen` run.
export const send = mutation({
args: { conversationId: v.id("conversations"), author: v.string(), body: v.string() },
returns: v.id("messages"),
shardBy: "conversationId",
handler: (ctx, args) =>
ctx.db.insert("messages", {
conversationId: args.conversationId,
author: args.author,
body: args.body,
}),
});
// Replace. There is no partial-patch method: read the document, then write back every field,
// changed or not (see Mutations: "Writing data").
export const edit = mutation({
args: { conversationId: v.id("conversations"), id: v.id("messages"), body: v.string() },
returns: v.null(),
shardBy: "conversationId",
handler: async (ctx, { id, body }) => {
const message = await ctx.db.get(id);
if (message === null) return null;
await ctx.db.replace(id, {
conversationId: message.conversationId,
author: message.author,
body,
edited: true,
});
return null;
},
});
// Delete.
export const remove = mutation({
args: { conversationId: v.id("conversations"), id: v.id("messages") },
returns: v.null(),
shardBy: "conversationId",
handler: async (ctx, { id }) => {
await ctx.db.delete(id);
return null;
},
});
// A single message, by id, used by the webhook action in the next step.
export const get = query({
args: { id: v.id("messages") },
returns: v.union(messageObject, v.null()),
handler: (ctx, { id }) => ctx.db.get(id),
});
// The whole conversation, live.
export const list = query({
args: { conversationId: v.id("conversations") },
returns: v.array(messageObject),
handler: (ctx, args) =>
ctx.db.query("messages", "by_conversation").eq("conversationId", args.conversationId).collect(),
});
// One page at a time, newest first.
export const listPaginated = query({
args: {
conversationId: v.id("conversations"),
cursor: v.optional(v.string()),
pageSize: v.optional(v.number()),
},
returns: v.object({
page: v.array(messageObject),
nextCursor: v.union(v.string(), v.null()),
hasMore: v.boolean(),
scanCapped: v.boolean(),
}),
handler: (ctx, args) =>
ctx.db
.query("messages", "by_conversation")
.eq("conversationId", args.conversationId)
.order("desc")
.paginate({ cursor: args.cursor ?? null, pageSize: args.pageSize ?? 20 }),
});A few things worth calling out:
- Every write to
messagesnames its shard.editandremoveonly strictly need a messageidto do their job, but becausemessagesis sharded, each mutation also takesconversationIdand declaresshardBy: "conversationId", the arg that tells the engine which shard's writer to run this transaction on. This is the one recurring cost of declaring a shard key: every mutation touching that table must be able to say, from its own arguments, which shard key value it's writing. (If you never plan to shard, just skip.shardKey/shardByentirely. An app that never declares either runs exactly as it would without this feature existing at all.) .eq("conversationId", id)narrows the read to one conversation's slice of theby_conversationindex, not the wholemessagestable. That narrowing is also what makes the subscription precise: a write to a different conversation never re-runs this query. See Reactivity..order("desc")reverses the index scan (newest-first) before.paginateslices it into pages. Useful for a chat thread, where you want the most recent messages first and older ones loaded on demand..paginate({ cursor, pageSize })returns{ page, nextCursor, hasMore, scanCapped }: a page of documents, an opaque cursor for the next call (nullonce there isn't one), whether more remain, and whether an internal scan cap was hit before filling the page (rare, and never a correctness problem, just possibly a shorter page than requested).v.union(messageObject, v.null())is how you type "this document, or nothing":ctx.db.getreturnsnullfor a missing id, never throws.
An action: notify a webhook
Actions are the only place fetch is allowed. Queries and
mutations must stay deterministic, so anything that talks to the outside world runs outside the
transaction, in an action. Create helipod/webhook.ts:
import { v } from "@helipod/values";
import { action } from "./_generated/server";
export const pingWebhook = action({
args: { url: v.string(), messageId: v.id("messages") },
returns: v.object({ ok: v.boolean(), status: v.number() }),
handler: async (ctx, { url, messageId }) => {
const message = await ctx.runQuery("messages:get", { id: messageId });
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ author: message?.author, body: message?.body, sentAt: Date.now() }),
});
return { ok: res.ok, status: res.status };
},
});ctx.runQuery is how an action reaches into the database. It runs messages:get as its own
independent read (an action has no ctx.db of its own). fetch and Date.now(), forbidden in a
query or mutation, work normally here. The result isn't reactive (an action has nothing for a
client to subscribe to), but its return value still resolves back to whoever called it, same as a
mutation's does. See Actions for the full picture, including
ctx.runMutation/ctx.runAction for orchestrating writes and other actions.
Build the web UI
Create a web/ directory alongside helipod/:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.js"></script>
</body>
</html>Now the client. This is the longest piece, so it's introduced in sections. They all belong in
one file, web/main.tsx.
Client setup
import { StrictMode, useState, type FormEvent } from "react";
import { createRoot } from "react-dom/client";
import { HelipodClient, webSocketTransport, anyApi, type OptimisticLocalStore } from "@helipod/client";
import { HelipodProvider, useQuery, useMutation, useAction, useHelipodClient } from "@helipod/client/react";
// Type-only imports, erased at bundle time, so this never pulls the server-side
// `@helipod/executor` re-exports in `_generated/server.ts` into the browser bundle. `anyApi`
// (below) is the real runtime value; `Api` just shapes it, at the import site, for free.
import type { Api } from "../helipod/_generated/api";
import type { Doc, Id } from "../helipod/_generated/dataModel";
const api = anyApi as Api;
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
const client = new HelipodClient(webSocketTransport(`${wsProtocol}://${location.host}/api/sync`));_generated/api.d.ts is types only (it declares the Api type, not a runtime value), and
_generated/server.ts's runtime api re-exports server-only code alongside it: fine to import
from a Node process, wasteful to ship to a browser. anyApi (from @helipod/client) is a
plain runtime proxy that turns api.messages.send into a { __path: "messages:send" }
reference. Casting it as Api gives you the exact same object, fully typed, with nothing
server-side dragged along. This is the pattern every helipod client entry point uses.
webSocketTransport opens the reactive connection to /api/sync, the same path helipod dev
serves the sync WebSocket on.
Conversations sidebar
function Sidebar({ selected, onSelect }: { selected: Id<"conversations"> | null; onSelect: (id: Id<"conversations">) => void }) {
const conversations = useQuery(api.conversations.list, {});
const create = useMutation(api.conversations.create);
const [title, setTitle] = useState("");
async function submit(e: FormEvent) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
const id = await create({ title: trimmed });
setTitle("");
onSelect(id);
}
return (
<aside>
<ul>
{conversations?.map((c) => (
<li key={c._id}>
<button aria-pressed={c._id === selected} onClick={() => onSelect(c._id)}>
{c.title}
</button>
</li>
))}
</ul>
<form onSubmit={submit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="New conversation…" />
<button type="submit">Create</button>
</form>
</aside>
);
}useQuery(api.conversations.list, {}) subscribes. The list re-renders on its own the moment
create commits a new row, including in this very component, since create's write and this
query's read are the same table. create({ title }) resolves with the new conversation's _id
(its returns: v.id("conversations") validator), which is why onSelect(id) can switch straight
to the conversation you just made.
The message thread, with an optimistic send
type Message = Doc<"messages">;
// An optimistically-inserted row carries a placeholder `_id` (a deterministic-but-non-decodable
// string, never a real `Id<"messages">`) until the real commit swaps it in. See
// docs/client/optimistic-updates.mdx for the recipe this type widening follows.
type PendingMessage = Message | (Omit<Message, "_id"> & { _id: string; pending: true });
// Module-scoped (not an inline closure) so `.withOptimisticUpdate` returns the SAME bound
// callable across re-renders. An inline arrow here would churn it every render.
function appendOptimistic(
store: OptimisticLocalStore,
args: { conversationId: Id<"conversations">; author: string; body: string },
): void {
const list = store.getQuery(api.messages.list, { conversationId: args.conversationId });
if (list === undefined) return; // nothing subscribed locally yet, nothing to patch
const pending: PendingMessage = {
_id: store.placeholderId("messages"), // deterministic across replays, never crypto.randomUUID()
_creationTime: store.now(), // fixed at entry creation, never Date.now()
conversationId: args.conversationId,
author: args.author,
body: args.body,
pending: true,
};
store.setQuery(api.messages.list, { conversationId: args.conversationId }, [...(list as PendingMessage[]), pending]);
}
function Thread({ conversationId, author }: { conversationId: Id<"conversations">; author: string }) {
const messages = useQuery(api.messages.list, { conversationId }) as PendingMessage[] | undefined;
const send = useMutation(api.messages.send).withOptimisticUpdate(appendOptimistic);
const edit = useMutation(api.messages.edit);
const remove = useMutation(api.messages.remove);
const pingWebhook = useAction(api.webhook.pingWebhook);
const [text, setText] = useState("");
function submit(e: FormEvent) {
e.preventDefault();
const body = text.trim();
if (!body) return;
void send({ conversationId, author, body });
setText("");
}
if (messages === undefined) return <p>Loading…</p>;
return (
<div>
<ul>
{messages.length === 0 && <li>No messages yet. Say hi.</li>}
{messages.map((m) => (
<li key={m._id} className={"pending" in m ? "pending" : undefined}>
<b>{m.author}</b>: {m.body} {m.edited && <em>(edited)</em>}
{!("pending" in m) && m.author === author && (
<>
<button
onClick={() => {
const next = prompt("Edit message", m.body);
if (next && next !== m.body) void edit({ conversationId, id: m._id, body: next });
}}
>
Edit
</button>
<button onClick={() => void remove({ conversationId, id: m._id })}>Delete</button>
</>
)}
{!("pending" in m) && (
<button
onClick={() =>
void pingWebhook({ url: "https://httpbin.org/post", messageId: m._id }).then((r) =>
console.log("webhook:", r),
)
}
>
Ping webhook
</button>
)}
</li>
))}
</ul>
<form onSubmit={submit}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message…" autoFocus />
<button type="submit">Send</button>
</form>
</div>
);
}Four hooks, four jobs:
useQuery(api.messages.list, { conversationId })subscribes; changingconversationId(switching the sidebar selection) unsubscribes the old query and subscribes the new one, and the component showsundefined(rendered here as "Loading…") until the first result for the new conversation arrives.useMutation(api.messages.send).withOptimisticUpdate(appendOptimistic)renders your own sent message instantly, in the same tick as the click.appendOptimisticruns synchronously against the local query cache before the mutation is even sent. The pending row is dropped the moment a real server push demonstrably includes the commit, never merely "when the promise resolves," so there's no flicker between the guessed row and the real one.placeholderId/now()(nevercrypto.randomUUID()/Date.now()) exist because this updater can replay more than once for the same pending mutation; using the real globals there would mint a fresh value on every replay and break React's keying. Full detail in Optimistic updates.useMutation(api.messages.edit)/useMutation(api.messages.remove)are plain callbacks. No optimistic update here, so an edit or delete round-trips before you see it, same assendwould without.withOptimisticUpdate.useAction(api.webhook.pingWebhook)is a plain callback too, exactly likeuseMutationwithout the subscription machinery. There's nothing to subscribe to, since an action's result isn't part of the reactive model.
One-shot pagination
listPaginated isn't wired to useQuery. It's a page-at-a-time read, not something you keep a
live subscription open on. Call it directly on the client through useHelipodClient():
function LoadOlder({ conversationId }: { conversationId: Id<"conversations"> }) {
const client = useHelipodClient();
const [older, setOlder] = useState<Message[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [hasMore, setHasMore] = useState(true);
async function loadMore() {
const result = await client.query(api.messages.listPaginated, { conversationId, cursor, pageSize: 20 });
setOlder((prev) => [...prev, ...result.page]);
setCursor(result.nextCursor ?? undefined);
setHasMore(result.hasMore);
}
return (
<div>
<ul>
{older.map((m) => (
<li key={m._id}><b>{m.author}</b>: {m.body}</li>
))}
</ul>
{hasMore && <button onClick={() => void loadMore()}>Load older messages</button>}
</div>
);
}useHelipodClient() returns the same HelipodClient instance <HelipodProvider> holds:
the escape hatch for anything that isn't a live subscription. client.query(ref, args) is a
one-shot read: it resolves once with the current result and then unsubscribes. Nothing here
stays live, so a new message arriving elsewhere doesn't reshuffle a page you've already loaded.
Each call passes the previous call's nextCursor to resume exactly where the last page left off.
Putting it together
function App() {
const [conversationId, setConversationId] = useState<Id<"conversations"> | null>(null);
const [author] = useState(() => `user-${Math.floor(Math.random() * 1000)}`);
return (
<div>
<Sidebar selected={conversationId} onSelect={setConversationId} />
{conversationId ? (
<>
<Thread conversationId={conversationId} author={author} />
<LoadOlder conversationId={conversationId} />
</>
) : (
<p>Create or pick a conversation to start chatting.</p>
)}
</div>
);
}
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<HelipodProvider client={client}>
<App />
</HelipodProvider>
</StrictMode>,
);
}<HelipodProvider client={client}> is what makes useQuery/useMutation/useAction/
useHelipodClient work anywhere beneath it in the tree: exactly one client, shared by the
whole app.
Bundle the client
bun build web/main.tsx --outfile web/main.jsThis produces the web/main.js that web/index.html loads. Re-run it whenever you change
web/main.tsx, or add --watch to have it rebuild automatically on save:
bun build web/main.tsx --outfile web/main.js --watchRun it
bunx helipod dev --web webhelipod dev generates helipod/_generated/, boots the reactive engine on local SQLite, serves
HTTP + the sync WebSocket + the dashboard. Because of --web web, it also serves your bundled UI
from the same origin and port, so there's no separate dev server or proxy to configure. It prints
the dashboard URL and the admin key on startup. Open the printed URL in your browser to see the
app itself.
Watch it react
- Create a conversation, pick it, and send a couple of messages. Each appears instantly: that's the optimistic update rendering before the round trip completes, then settling into the real committed row with no visible change.
- Edit one of your own messages, then delete another. Both round-trip (no optimistic update on those two) and update the thread once they commit.
- Click Load older messages a few times as you add more. That's
listPaginatedwalking backward through history one page at a time. - Open the same URL in a second browser tab, side by side with the first. Pick the same conversation.
- Send a message in one tab. It appears in the other tab too, with no refresh: the same mutation-commits-a-write-set / subscription-re-runs loop from How it works, now driving a UI you wrote yourself. Edit or delete a message in one tab and watch the other tab update as well.
There is no client-side polling, no manual refetch, and no invalidation code anywhere in
web/main.tsx. Every re-render above is the engine intersecting a commit's write-set against a
live query's read-set and pushing the result to every open subscription that matched.
Hot reload while you work
helipod dev watches helipod/ and reloads automatically: edit a query, mutation, or schema
field and the running server picks it up without a restart. Try adding a field to messages or
tweaking list's handler and watch the dashboard/your app reflect it on the next read. The web UI
is not part of that watch loop. After editing web/main.tsx, re-run bun build (or keep
--watch running from step 7) and reload the browser tab to see the change; there's no HMR for
the static bundle in this setup.
What you built
A two-table schema with an index and a shard key, five backend functions covering every write a
mutation can make (insert, replace, delete) plus both ways to read (a full collect and a
paginated walk), one action reaching out to the network, and a React UI wired to all of it with
HelipodProvider, useQuery, useMutation (with an optimistic update), useAction, and
direct one-shot client.query() calls. That's the same shape every helipod app is built from,
whether it's a chat app, a dashboard, or something much bigger.
From here:
- Client SDK: the full hook and client surface, including connection state and ephemeral broadcasts (presence/typing) that bypass the engine entirely.
- Optimistic updates: the full reconciliation contract behind
.withOptimisticUpdate, purity rules, and reconnect behavior. - Offline sync: durable offline mutations that survive a reload or crash, for when a brief reconnect isn't enough.
- Reactivity: exactly how precise the invalidation you just saw really is, and when a query falls back to coarser table-level matching.
- Scaling (fleet): what
.shardKey/shardByactually unlock once you outgrow a single node. - Components: opt-in pieces like scheduling, workflows, triggers, and auth that compose onto an app exactly like this one.
- Self-hosting and Deploy & build:
taking this from
helipod devto a real deployment.