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:

MemberSignature
useQuery`(name, ...args) => Result \undefined`
useMutation(name) => (...args) => Promise<Result>
useAction(name) => (...args) => Promise<Result>
usePaginatedQuery(name, args, options?: { initialNumItems?: number }) => PaginatedQueryResult<Item>
storageUpload 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>
  );
}

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>
  );
}

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>
  );
}
ExportUse
RouterWraps the app.
Routes, RouteRenders 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.