Client
The browser side of a fold: the typed client, query and mutation hooks, live updates, pagination, errors, and routing.
client/index.tsx must export a Preact component named App. Client code may import from @aozumi/fold/client, preact, preact/hooks, preact/jsx-runtime, preact/jsx-dev-runtime, and relative files. Style with complete Tailwind class names in JSX. Fold compiles the CSS from your client files.
createClient
import { createClient } from "@aozumi/fold/client";
import type app from "../server/index";
const client = createClient<typeof app>();
Use a type-only import of the server. The client then knows every query, mutation, and action name, its arguments, and its result type. Results are typed as the JSON your server returns.
createClient(options?) takes { optimistic?: boolean }. See Optimistic updates.
The returned client has:
| Member | Signature | |
|---|---|---|
useQuery | `(name, ...args) => Result \ | undefined` |
useMutation | (name) => (...args) => Promise<Result> | |
useAction | (name) => (...args) => Promise<Result> | |
usePaginatedQuery | (name, args, options?: { initialNumItems?: number }) => PaginatedQueryResult<Item> | |
storage | Upload API. Local dev only for now. See Storage. |
Queries
function TaskList() {
const tasks = client.useQuery("myTasks");
if (tasks === undefined) {
return <p>Loading</p>;
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
}
- Returns
undefineduntil the first result arrives. A realnullresult is returned asnull. - Pass the query's argument after the name:
client.useQuery("tasksForList", { listId }). - If the query throws on the server,
useQuerythrows the error during render. Wrap the component inErrorBoundary.
Live updates
Queries are live. Each page keeps one WebSocket to the fold. When a mutation, a writable endpoint, or a guest upgrade changes a table, every subscribed query that read that table runs again and the new result reaches the page. You do not refetch by hand.
After a redeploy, open pages are told to pick up the new version.
Mutations
function AddTask() {
const addTask = client.useMutation("addTask");
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const title = String(new FormData(form).get("title") ?? "").trim();
if (!title) {
return;
}
await addTask(title);
form.reset();
}
return (
<form onSubmit={(event) => void onSubmit(event)}>
<input name="title" className="border px-2 py-1" />
<button type="submit">Add</button>
</form>
);
}
- The promise resolves with the mutation's return value after the caller's queries show the write. It rejects with the server's error message.
- A page sends at most 4 mutations or actions at once. Later calls wait their turn and are sent in call order. If one write depends on another,
awaitthe first.
Actions
const noteCount = client.useAction("noteCount");
const { total } = await noteCount();
Actions are one-shot calls. They are not live and cannot write to the database.
Paginated queries
Server:
feed: query(async (ctx, args: { pagination: PaginationOptions }) =>
ctx.db.posts.withIndex("by_creation").order("desc").paginate(args.pagination)
)
Client:
function Feed() {
const feed = client.usePaginatedQuery("feed", {}, { initialNumItems: 20 });
return (
<div>
{feed.page.map((post) => (
<p key={post.id}>{post.body}</p>
))}
{feed.isDone ? null : <button type="button" onClick={() => feed.loadMore()}>Load more</button>}
</div>
);
}
The query must take one object argument with a pagination field and return the result of paginate(). Pass the other fields as args; Fold adds pagination. The result has page (all loaded items), isDone, continueCursor, loadMore(), and reset(). initialNumItems defaults to 20. Every loaded page stays live.
Errors
import { ErrorBoundary } from "@aozumi/fold/client";
<ErrorBoundary fallback={(error, retry) => (
<div>
<p role="alert">{error.message}</p>
<button type="button" onClick={retry}>Try again</button>
</div>
)}>
<TaskList />
</ErrorBoundary>
Optimistic updates
createClient({ optimistic: false }) turns optimistic updates off for the page. When a host supports them, Fold can show a mutation's writes before the server confirms them, with no app code. Today neither npx @aozumi/fold dev nor hosted folds offer them, so every mutation updates the page when the server answers. Write your UI so it does not depend on instant updates.
Routing
import { Link, Route, Router, Routes, useNavigate, useParams } from "@aozumi/fold/client";
function ListPage() {
const { listId } = useParams<{ listId: string }>();
const navigate = useNavigate();
return (
<section>
<h1>List {listId}</h1>
<button type="button" onClick={() => navigate("/")}>Back</button>
</section>
);
}
export function App() {
return (
<Router>
<nav>
<Link to="/">Home</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/lists/:listId" element={<ListPage />} />
<Route path="*" element={<p>Not found</p>} />
</Routes>
</Router>
);
}
| Export | Use |
|---|---|
Router | Wraps the app. |
Routes, Route | Renders the first Route whose path matches. :name matches one segment; * matches the rest. |
Link | <a> that navigates without a reload. Props: to, replace?, plus any anchor props. |
navigate(to, { replace? }), useNavigate() | Navigate from code. |
useLocation() | { pathname, search, hash, href } |
useParams() | Values for :name segments, and *. |
There is no file-based routing. Deep links work: a browser request for any page path that is not an endpoint gets the app.
Auth hooks
useAuth, SignInWithGoogle, signInWithGoogle, signOut, getIdentity, retryAuth, and canAccessApp are covered in Auth.
Standalone hooks
useQuery, useMutation, useAction, and usePaginatedQuery are also exported directly from @aozumi/fold/client, untyped: useQuery<Task[]>("myTasks"). Prefer the typed createClient versions.