Skip to content

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:

InteractionContract input
List a pagelistTodos: query parameters limit and offset
Create a TodocreateTodo: body { title }
Edit completionupdateTodo: 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.

tsx
// 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.

tsx
// 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.

tsx
// 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:

tsx
// 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.

components/app-sidebar.tsx

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:

bash
bun run format
bun beignet db migrate
bun beignet check
bun run dev

Sign in and open localhost:3000/todo-workbench. Use a new account for an empty starting list. Check these behaviors:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

SymptomCheck
The page reports a missing Query ClientMount it under the existing app/providers.tsx; do not create a client inside every component render.
New or edited records do not appearKeep invalidates and callbacks inside mutationOptions. Retry a failed list refresh rather than repeating a successful write.
A form rejects fields the API should acceptBind it to the correct contract's body schema. Path IDs and auth-derived ownership are not body fields.
A request returns 401 or 403Check 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 switchingCancel active queries and clear private caches at the identity change, then remount or reset feature forms.

Work with your agent

text
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.