Connect forms and lists
Build a Todo workbench with a create form, editable completion status, and a paginated list. Use the starter's existing API contracts for validation, requests, and cache refreshes. Handle failed requests without losing a draft, and clear private cached data when signing out.
Before you start
Use the Next.js and SQLite app from Quickstart, with sign-in and Todos working. Run commands from the app root. You do not need the Projects tutorial or the resource-protection guide, and no extra packages are required.
Keep the existing client/index.ts, client/forms.ts, Query Client provider,
and authenticated app/(app)/layout.tsx. This guide adds /todo-workbench
alongside /todos; both screens use the same API and saved records. In a
customized app, merge these changes with your existing components and sign-out
flow instead of replacing them.
The contracts determine what each interaction can send:
| Interaction | Contract input |
|---|---|
| List a page | listTodos: query parameters limit and offset |
| Create a Todo | createTodo: body { title } |
| Edit completion | updateTodo: path { id } and body { completed } |
The starter's update contract does not edit titles. Ownership comes from the server's session and policy, never a form field. Browser components import contracts and schemas; use cases and repository adapters stay on the server.
Create the form
Add features/todos/components/todo-create-form.tsx. rhf(createTodo) validates
the title with the contract's body schema. rq(createTodo) supplies the typed
mutation; invalidates refreshes active Todo lists after a successful write.
// features/todos/components/todo-create-form.tsx
"use client";
import { rootFormError } from "@beignet/react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { useId } from "react";
import { rq } from "@/client";
import { rhf } from "@/client/forms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTodo, listTodos } from "../contracts";
export function TodoCreateForm({ onCreated }: { onCreated: () => void }) {
const titleId = useId();
const form = rhf(createTodo).useForm({ defaultValues: { title: "" } });
const create = useMutation(
rq(createTodo).mutationOptions({
invalidates: () => [rq(listTodos).contractFilter()],
onSuccess: () => {
form.reset();
onCreated();
},
onError: (error) =>
form.setError(
"root",
rootFormError(error, "Could not create the todo."),
),
}),
);
return (
<form
aria-label="Create todo"
className="space-y-3"
onSubmit={form.handleSubmit((body) => {
if (create.isPending) return;
form.clearErrors("root");
create.mutate({ body });
})}
>
<Label htmlFor={titleId}>New todo</Label>
<Input
id={titleId}
{...form.register("title")}
readOnly={create.isPending}
aria-invalid={!!form.formState.errors.title}
aria-describedby={
form.formState.errors.title ? `${titleId}-error` : undefined
}
/>
{form.formState.errors.title && (
<p id={`${titleId}-error`} role="alert">
{form.formState.errors.title.message}
</p>
)}
{form.formState.errors.root && (
<p role="alert">{form.formState.errors.root.message}</p>
)}
<Button type="submit" disabled={create.isPending}>
{create.isPending ? "Creating…" : "Create todo"}
</Button>
</form>
);
}An empty title or one over 120 characters fails before a request is sent. The server independently validates the request. If you changed that constraint in Quickstart, the form uses your current schema.
A successful write resets the form and calls onCreated so the list can return
to its first page. A failed write keeps the title and displays rootFormError's
message. Keep callbacks inside mutationOptions so they compose with
invalidation. isPending includes the write and its awaited invalidations;
keep fields read-only and submit disabled until both finish.
Edit completion
Add features/todos/components/todo-completion-form.tsx. The form owns only
completed; the stored Todo supplies the path ID when submitting.
// features/todos/components/todo-completion-form.tsx
"use client";
import { rootFormError } from "@beignet/react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { useId } from "react";
import { rq } from "@/client";
import { rhf } from "@/client/forms";
import { Button } from "@/components/ui/button";
import { listTodos, updateTodo } from "../contracts";
import type { Todo } from "../schemas";
export function TodoCompletionForm({ todo }: { todo: Todo }) {
const completedId = useId();
const form = rhf(updateTodo).useForm({
values: { completed: todo.completed },
resetOptions: { keepDirtyValues: true },
});
const update = useMutation(
rq(updateTodo).mutationOptions({
invalidates: () => [rq(listTodos).contractFilter()],
onSuccess: (saved) => form.reset({ completed: saved.completed }),
onError: (error) =>
form.setError("root", rootFormError(error, "Could not save the todo.")),
}),
);
return (
<form
aria-label={`Update ${todo.title}`}
className="space-y-3"
onSubmit={form.handleSubmit((body) => {
if (update.isPending) return;
form.clearErrors("root");
update.mutate({ path: { id: todo.id }, body });
})}
>
<h3 className="font-medium break-words">{todo.title}</h3>
<div className="flex flex-wrap items-center gap-3">
<input
id={completedId}
type="checkbox"
{...form.register("completed")}
disabled={update.isPending}
/>
<label htmlFor={completedId}>Completed</label>
<Button type="submit" size="sm" disabled={update.isPending}>
{update.isPending ? "Saving…" : "Save"}
</Button>
</div>
{form.formState.errors.root && (
<p role="alert">{form.formState.errors.root.message}</p>
)}
</form>
);
}values updates a pristine form when fresh data arrives. keepDirtyValues
preserves an unsaved checkbox change during a background refresh. On success,
reset to the returned record so that the saved value becomes the new baseline.
On failure, retain the selection and show the error next to that Todo.
Show one page at a time
Add features/todos/components/todo-workbench.tsx. Each offset has its own
generated query key. The UI waits for that page's response instead of showing
the previous page under the new page number.
// features/todos/components/todo-workbench.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { rq } from "@/client";
import { Button } from "@/components/ui/button";
import { listTodos } from "../contracts";
import { TodoCompletionForm } from "./todo-completion-form";
import { TodoCreateForm } from "./todo-create-form";
const pageSize = 5;
export function TodoWorkbench() {
const [offset, setOffset] = useState(0);
const todos = useQuery(
rq(listTodos).queryOptions({ query: { limit: pageSize, offset } }),
);
return (
<div className="max-w-2xl space-y-8">
<TodoCreateForm onCreated={() => setOffset(0)} />
<section aria-label="Your todos" className="space-y-4">
<h2 className="text-lg font-semibold">Your todos</h2>
{todos.isPending && <p role="status">Loading todos…</p>}
{todos.isError && (
<div role="alert" className="space-y-2">
<p>
{todos.data
? "Could not refresh todos. Your last successful change may not be shown."
: "Could not load todos. Check your connection and sign-in."}
</p>
<Button
type="button"
variant="outline"
disabled={todos.isFetching}
onClick={() => void todos.refetch()}
>
Try again
</Button>
</div>
)}
{todos.data && (
<>
{todos.data.items.length === 0 ? (
<p>
{offset === 0
? "No todos yet. Create your first one above."
: "No todos on this page. Go back to the previous page."}
</p>
) : (
<ul className="divide-y rounded-lg border">
{todos.data.items.map((todo) => (
<li key={todo.id} className="p-4">
<TodoCompletionForm todo={todo} />
</li>
))}
</ul>
)}
{todos.isFetching && <p role="status">Refreshing todos…</p>}
</>
)}
<nav
aria-label="Todo pages"
className="flex flex-wrap items-center gap-3"
>
<Button
type="button"
variant="outline"
disabled={offset === 0 || todos.isFetching}
onClick={() => setOffset((value) => Math.max(0, value - pageSize))}
>
Previous
</Button>
<span>Page {offset / pageSize + 1}</span>
<Button
type="button"
variant="outline"
disabled={
!todos.data?.page.hasMore || todos.isFetching || todos.isError
}
onClick={() => setOffset((value) => value + pageSize)}
>
Next
</Button>
</nav>
</section>
</div>
);
}The API supplies hasMore; do not guess it from the number of rows. Creating
from a later page returns to the first page, where the starter lists the newest
Todos first. Both forms invalidate the entire listTodos contract so previously
visited pages become stale too.
An initial load failure shows a retry action. A refresh failure keeps the existing rows and explains that they may be stale. If the write succeeded but the refresh failed, use Try again to retry the read; do not submit the write again to refresh the screen.
This screen needs no feature-level query wrapper: options live beside the components that use them. Extract a shared helper when several screens need the same pagination defaults, cache rules, or optimistic updates.
Mount the page and handle sign-out
Create a page under the starter's authenticated layout:
// app/(app)/todo-workbench/page.tsx
import { TodoWorkbench } from "@/features/todos/components/todo-workbench";
export default function TodoWorkbenchPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Todo workbench</h1>
<TodoWorkbench />
</div>
);
}Add a sidebar link and clear the Query Client after a successful sign-out. Session cookies are not part of generated query keys, so private responses must not be reused for another account. Cancel reads before clearing the cache; the typed query functions forward TanStack Query's abort signal.
123456712 unmodified lines2021222324252611 unmodified lines383940414243444546474849505152404142535455565758596061626333 unmodified lines9798991001011021031041051061074 unmodified lines112113114115116117118"use client";
import { useQueryClient } from "@tanstack/react-query";import { useState } from "react";import { FileJsonIcon, LayoutDashboardIcon,12 unmodified linesconst navLinks = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboardIcon }, { href: "/todos", label: "Todos", icon: ListTodoIcon }, { href: "/todo-workbench", label: "Todo workbench", icon: ListTodoIcon }, { href: "/settings", label: "Settings", icon: SettingsIcon },];
11 unmodified lines}) { const pathname = usePathname(); const router = useRouter(); const queryClient = useQueryClient(); const [signingOut, setSigningOut] = useState(false); const [signOutError, setSignOutError] = useState("");
async function signOut() { if (signingOut) return; setSigningOut(true); setSignOutError(""); try { const result = await authClient.signOut(); if (result.error) throw new Error(result.error.message); await queryClient.cancelQueries(); await authClient.signOut(); router.push("/"); router.refresh(); queryClient.clear(); router.push("/"); router.refresh(); } catch { setSignOutError("Could not sign out. Try again."); } finally { setSigningOut(false); } }
return (33 unmodified lines </a> ) : null} </nav> {signOutError && ( <p role="alert" className="px-3 text-sm"> {signOutError} </p> )} <div className="flex items-center gap-2 border-t p-3"> <div className="min-w-0 flex-1"> <p className="truncate text-sm font-medium">{user.name}</p>4 unmodified lines variant="ghost" size="icon" aria-label="Sign out" disabled={signingOut} onClick={signOut} > <LogOutIcon />A failed sign-out keeps the current screen and reports the error. The cache is cleared only after sign-out succeeds. Apply the same reset rule to any other account-switch flow in your app. If you add cross-tab session handling, impersonation, or persisted caches, connect their identity changes to cache and form-state cleanup too. Server-side authorization is required regardless of what is cached in the browser.
Verify the complete flow
Format the files, apply the starter's migrations if needed, and run its checks:
bun run format
bun beignet db migrate
bun beignet check
bun run devSign in and open localhost:3000/todo-workbench. Use a new account for an empty starting list. Check these behaviors:
- Submit an empty title, then a title over your schema's maximum. Expect a field error and no create request in the browser's Network panel.
- Create six Todos. Expect the form to reset after each successful write, five records on the first page, and the remaining record on Next. Create from page two and confirm you return to page one with the new Todo visible.
- Change a Todo's Completed checkbox and press Save. Reload the page to
confirm the value was saved. The request body contains
completed; its ID belongs in the URL. - In browser development tools, slow requests to inspect pending controls. Then simulate a failed create or update: the draft stays in place and the form shows an error. Restore connectivity before retrying.
- Fail a list request and use Try again after restoring it. If a write already succeeded, retry the list rather than creating or saving again.
- Sign out and sign in as a different account. Expect that account's list, with no previous-account rows or unfinished form values. Sign back in to confirm the original account's records remain saved.
Keep the starter's existing tests. Follow Test a feature to extend API, ownership, and persistence checks. When testing the UI, mount it with an isolated Query Client and verify the displayed values and actual requests, including rejected submissions, failed writes, pagination, pending refreshes, and sign-out cleanup.
Troubleshooting
| Symptom | Check |
|---|---|
| The page reports a missing Query Client | Mount it under the existing app/providers.tsx; do not create a client inside every component render. |
| New or edited records do not appear | Keep invalidates and callbacks inside mutationOptions. Retry a failed list refresh rather than repeating a successful write. |
| A form rejects fields the API should accept | Bind it to the correct contract's body schema. Path IDs and auth-derived ownership are not body fields. |
A request returns 401 or 403 | Check the session and ownership policy. A disabled button does not grant permission; signing in again does not authorize access to another account's data. |
| Previous-account rows appear after switching | Cancel active queries and clear private caches at the identity change, then remount or reset feature forms. |
Work with your agent
Read https://beignetjs.com/markdown/agent-instructions.md and the app's local
instructions. Follow https://beignetjs.com/markdown/guides/forms-and-lists.md.
Use the starter's existing Todos contracts, client helpers, Query Client provider,
and authenticated layout. Add a paginated list, create form, and completion form.
Keep path IDs separate from body fields and ownership on the server. Preserve
customized components and auth behavior. Do not add another client or copy
validation rules into an unrelated schema.
Verify invalid input sends no request, failed writes retain drafts, pending
controls cover cache refreshes, pagination uses the response's hasMore value,
and successful sign-out clears private data before another account uses the app.
Test a failed refresh separately from a failed write. Run format and beignet
check. Report changed files, results, skipped checks, and steps I can repeat.Use React Query and React Hook Form for the full adapter APIs. To build an API and UI together, follow the Projects tutorial.