Walkthrough
Build a live note app end to end — schema, query, mutation, and a React UI that updates itself.
Everything below is real code you can paste into a project from getting started. At the end you will have a note app that two browsers share.
The finished version — with sign-in, presence, and conflict handling — is
example/note in the repository.
1. Describe the data
A schema declares tables and the indexes queries may use. Indexes are part of the contract, not a performance afterthought: the reactive engine uses them to work out who to notify.
import { defineSchema, defineTable, schema } from "@highx/core";
const noteRow = schema((input: unknown): Record<string, unknown> => {
if (typeof input !== "object" || input === null) throw new Error("Expected a note");
return { ...input };
});
export const databaseSchema = defineSchema({
notes: defineTable("notes", noteRow, [
{ name: "byCreatedAt", fields: ["createdAt"] },
{ name: "byColor", fields: ["color", "createdAt"] },
]),
});Every row gets an id automatically. → Schema
2. Read with a query
import { objectSchema, query } from "@highx/core";
export const listNotes = query({
name: "notes.list",
args: emptyArgs,
returns: noteRows,
auth: true,
handler: (ctx) => ctx.db.query("notes").withIndex("byCreatedAt").order("desc").take(50),
});A query is read-only and deterministic — no fetch, no writes, no clock — which
is what makes it safe to re-run automatically. → Queries
3. Write with a mutation
import { mutation } from "@highx/core";
export const saveNote = mutation({
name: "notes.save",
args: noteArgs,
returns: noteId,
auth: can("notes:write"),
handler: (ctx, args) => ctx.db.table("notes").insert({
...args,
authorId: ctx.identity.userId,
createdAt: ctx.now(),
}),
});The handler is one transaction: throw, and nothing is written. ctx.identity is
the verified caller — never take a user or tenant ID from arguments.
→ Mutations
4. Regenerate
bunx highx generateThis writes api, a typed object of your function paths. Exporting a descriptor
is what exposes it; a function you don't export can't be called from a browser.
5. Show it in React
import { createRoot } from "react-dom/client";
import { HighXProvider } from "@highx/react";
import { createBrowserTransport } from "@highx/client";
import { Notes } from "./notes.js";
// One connection for the whole app. Created outside any component, so a
// re-render cannot open a second socket.
const transport = createBrowserTransport("wss://localhost:8787/ws");
createRoot(document.getElementById("root")!).render(
<HighXProvider cache={transport.cache} transport={transport}>
<Notes />
</HighXProvider>,
);import { useMutation, useQuery } from "@highx/react";
import { api } from "../.highx/api.js";
export function Notes() {
const notes = useQuery(api.notes.list, {});
const save = useMutation(api.notes.save);
if (notes.isLoading) return <p>Loading…</p>;
return (
<>
<button onClick={() => save({ title: "New", text: "…", color: "amber" })}>
Add note
</button>
<ul>
{(notes.data ?? []).map((note) => (
<li key={String(note["id"])}>{String(note["title"])}</li>
))}
</ul>
</>
);
}Open it in two tabs. Adding a note in one updates the other. There is no
refetch to call and no useEffect to write. → React
Give the provider the transport's own cache, as above. Two caches means patches arrive in one while components read the other — the UI loads correctly and then never updates again.
6. Reach the outside world
A mutation cannot call an API; that is what makes it a transaction. An action
can, and composes mutations through ctx.run:
export const summarize = action({
name: "notes.summarize",
args: idArgs,
returns: summary,
auth: can("notes:write"),
handler: async (ctx, args) => {
const note = ctx.db.table("notes").get(args.id);
const response = await ctx.fetch("https://api.example.com/summarize", {
method: "POST",
body: JSON.stringify({ text: note?.["text"] }),
});
return ctx.run(saveSummary, { id: args.id, summary: await response.json() });
},
});→ Actions
7. React to changes in the background
To run something whenever a set of rows changes — not on a schedule, and not by polling:
export const onUrgent = trigger({
name: "notes.onUrgent",
watch: { table: "notes", index: "byColor", equals: { color: "red" } },
handler: async (ctx, event) => {
for (const note of event.entered) await notifyOnCall(ctx, note);
},
});→ Triggers