Client
#Generated API
onebase dev (or onebase codegen) writes onebase/_generated/api.ts. Import api from it for type-checked function references:
import { api } from "../onebase/_generated/api";
await client.mutation(api.tasks.add, { text: "Hi" }); // args and return type are checkedWithout the generated types you can use anyApi (untyped) or plain strings like "tasks:add".
#Connecting to your backend
When onebase serves your frontend (from public/), the client connects to the same address and you don't pass a URL. When the frontend is hosted somewhere else (Vercel, Netlify, a CDN, a mobile app), pass the backend's URL:
const client = new OnebaseClient("https://api.example.com");- WebSocket: derived automatically (
https://api.example.com→wss://api.example.com/api/sync). - Per environment: keep the URL in an environment variable, e.g.
new OnebaseClient(import.meta.env.VITE_ONEBASE_URL). - CORS: any origin can call the API by default. Restrict it with
--cors https://app.example.com(comma-separate several origins). - File URLs: start the server with
--url https://api.example.com, soctx.storage.getUrl()returns public addresses instead oflocalhost.
#Browser
import { OnebaseClient } from "onebase/browser";
const client = new OnebaseClient("https://api.example.com"); // defaults to the page's origin
const stop = client.onUpdate(api.tasks.list, {}, (tasks) => render(tasks), (err) => showError(err));
await client.mutation(api.tasks.add, { text: "Write docs" });
const result = await client.action(api.ai.summarize, { id });
const once = await client.query(api.tasks.list, {});
stop(); // unsubscribeThe client uses one WebSocket:
- It reconnects with backoff and re-subscribes automatically.
client.onConnectionChange(state => ...)reports"connecting","open"or"closed".- If the connection drops before a call is answered, the call rejects instead of being retried silently, so you never run a mutation twice by accident.
Without a bundler: every onebase server serves the client as an ES module:
<script type="module">
import { OnebaseClient, anyApi as api } from "/_onebase/client.js";
</script>#React
import { OnebaseClient, OnebaseProvider, useMutation, useQuery } from "onebase/react";
import { api } from "../onebase/_generated/api";
const client = new OnebaseClient(import.meta.env.VITE_ONEBASE_URL);
export function App() {
return (
<OnebaseProvider client={client}>
<Tasks />
</OnebaseProvider>
);
}
function Tasks() {
const tasks = useQuery(api.tasks.list); // undefined while loading
const add = useMutation(api.tasks.add);
if (tasks === undefined) return <p>Loading…</p>;
return (
<>
{tasks.map((t) => <p key={t._id}>{t.text}</p>)}
<button onClick={() => add({ text: "New task" })}>Add</button>
</>
);
}| Hook | |
|---|---|
useQuery(ref, args | "skip") | live result; undefined while loading; errors throw to the nearest error boundary |
useMutation(ref) | returns (args) => Promise |
useAction(ref) | returns (args) => Promise |
usePaginatedQuery(ref, args, { initialNumItems }) | { results, status, isLoading, loadMore(n) }; every loaded page stays live; errors throw to the nearest error boundary |
useConnectionState() | "connecting" | "open" | "closed" |
useOnebaseAuth() | { isLoading, isAuthenticated, error } (also exported as useConvexAuth) |
useAuthActions() | { signIn, signOut } for built-in sign-in |
Porting from Convex: the aliases ConvexProvider and ConvexReactClient are exported too, so code written for Convex works after changing the import path.
#Optimistic updates
An optimistic update shows a mutation's expected effect immediately, before the server replies. When the mutation finishes, the server's real result takes its place. If the mutation fails, the update is undone.
const like = useMutation(api.posts.like).withOptimisticUpdate((store, { id }) => {
const posts = store.getQuery(api.posts.list, {});
if (posts !== undefined) {
store.setQuery(api.posts.list, {}, posts.map((p) => (p._id === id ? { ...p, likes: p.likes + 1 } : p)));
}
});Without React, pass it as an option:
await client.mutation(api.posts.like, { id }, { optimisticUpdate: (store, { id }) => { /* same as above */ } });The store covers the queries this client is subscribed to:
store.getQuery(ref, args) | current result, or undefined if not subscribed or loading |
store.getAllQueries(ref) | [{ args, value }] for every subscribed instance of the query |
store.setQuery(ref, args, value) | replaces the result until the mutation finishes; undefined shows it as loading |
- Don't modify results in place. Build new arrays and objects, as above.
- It may run more than once: first when you call the mutation, then again on top of each new server result that arrives while the mutation is in flight. Keep it a pure function of
storeandargs. - No flicker: the server sends a mutation's result together with the query updates that include its writes, and the client applies them in one step.
#Offline
Opt in, and the client keeps working through drops in connectivity and reloads while offline:
const client = new OnebaseClient("https://api.example.com", { offline: true });| Cached results | the newest 100 query results (set offline: { maxCachedQueries }) are kept in localStorage, so a reload while offline shows them immediately; live data replaces them when it arrives |
| Queued mutations | mutations made while disconnected are stored and sent, in the order they were made, once the connection returns — across reloads too |
client.queuedMutations | how many are still queued |
client.clearQueuedMutations() | drops them (the promises from when they were made reject) |
Details worth knowing:
- Each queued mutation is sent at most once per session. One that was sent but lost with the connection is never silently re-sent — it may or may not have run — matching how every other call behaves. Make such mutations idempotent if that matters, or send them from an action with its own deduping.
- Cached results belong to the signed-in user. With a token configured they're held back until the server confirms the same user is asking, and the cache is cleared when the user changes: one user never sees another's data.
- Queued mutations belong to the user who made them. They're sent only once the server confirms that same user is asking; if someone else is signed in by then they're dropped, never sent as the new user. (One made before the server ever confirmed who is asking is sent for whoever is confirmed first.)
- Optimistic updates don't survive a reload. They cover what happens while the page is open.
- Pass
offline: { storage }to store elsewhere (any object withgetItem/setItem/removeItem); in browsers the default islocalStorage. The Swift client works the same way with a directory on disk.
#Authentication
With built-in sign-in:
await client.signIn("password", { flow: "signIn", email, password });
await client.signIn("github"); // goes to GitHub and comes back signed in
client.authState; // { isLoading, isAuthenticated, error }
client.onAuthChange((state) => render(state));
await client.signOut();With an external provider:
client.setAuth(async () => await getTokenFromYourAuthProvider()); // called on every (re)connect
client.clearAuth();Either way, the server re-runs your subscriptions as that user. See Auth.
#Scripts and servers
OnebaseHttpClient uses plain HTTP, with no WebSocket and no subscriptions:
import { OnebaseHttpClient } from "onebase/browser";
const http = new OnebaseHttpClient("http://localhost:8090");
http.setAuth(token);
await http.mutation(api.tasks.add, { text: "From a cron script" });#HTTP API
Any language can call functions over HTTP:
curl -X POST http://localhost:8090/api/mutation \
-H "Content-Type: application/json" \
-d '{"path": "tasks:add", "args": {"text": "From curl"}}'{ "status": "success", "value": "k3m8x…" }| Endpoint | |
|---|---|
POST /api/query, /api/mutation, /api/action | { path, args } → { status, value } or { status: "error", errorMessage, errorData } |
POST /api/run | same, for any function type |
GET /api/health | { ok, functions, subscriptions } |
WS /api/sync | the live protocol used by the clients (spec) |
Send Authorization: Bearer <token> to call as a user. Writing a client for another platform?
The client protocol spec is the contract; the Swift client
(docs) is a full example, with offline support and optimistic updates.