--- title: "Connect forms and lists" description: "Build a paginated Todo list and contract-validated forms, handle request failures, refresh cached data, and clear private state on sign-out." url: "https://www.beignetjs.com/guides/forms-and-lists" markdown_url: "https://www.beignetjs.com/markdown/guides/forms-and-lists.md" framework_version: "0.0.65" documentation_scope: "current-checkout" source_revision: "ba3f49c103f3c4515e8ca14e07651fe5b83e2cec" source_modified: true --- # 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](https://www.beignetjs.com/markdown/getting-started.md), 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. ```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 (
{ if (create.isPending) return; form.clearErrors("root"); create.mutate({ body }); })} > {form.formState.errors.title && ( )} {form.formState.errors.root && (

{form.formState.errors.root.message}

)}
); } ``` 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 (
{ if (update.isPending) return; form.clearErrors("root"); update.mutate({ path: { id: todo.id }, body }); })} >

{todo.title}

{form.formState.errors.root && (

{form.formState.errors.root.message}

)}
); } ``` `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 (
setOffset(0)} />

Your todos

{todos.isPending &&

Loading todos…

} {todos.isError && (

{todos.data ? "Could not refresh todos. Your last successful change may not be shown." : "Could not load todos. Check your connection and sign-in."}

)} {todos.data && ( <> {todos.data.items.length === 0 ? (

{offset === 0 ? "No todos yet. Create your first one above." : "No todos on this page. Go back to the previous page."}

) : (
    {todos.data.items.map((todo) => (
  • ))}
)} {todos.isFetching &&

Refreshing todos…

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

Todo workbench

); } ``` 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. ```diff --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,5 +1,7 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; import { FileJsonIcon, LayoutDashboardIcon, @@ -18,6 +20,7 @@ const 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 }, ]; @@ -35,11 +38,26 @@ }) { const pathname = usePathname(); const router = useRouter(); + const queryClient = useQueryClient(); + const [signingOut, setSigningOut] = useState(false); + const [signOutError, setSignOutError] = useState(""); async function signOut() { - await authClient.signOut(); - router.push("/"); - router.refresh(); + if (signingOut) return; + setSigningOut(true); + setSignOutError(""); + try { + const result = await authClient.signOut(); + if (result.error) throw new Error(result.error.message); + await queryClient.cancelQueries(); + queryClient.clear(); + router.push("/"); + router.refresh(); + } catch { + setSignOutError("Could not sign out. Try again."); + } finally { + setSigningOut(false); + } } return ( @@ -79,6 +97,11 @@ ) : null} + {signOutError && ( +

+ {signOutError} +

+ )}

{user.name}

@@ -89,6 +112,7 @@ variant="ghost" size="icon" aria-label="Sign out" + disabled={signingOut} onClick={signOut} > ``` 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](http://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](https://www.beignetjs.com/markdown/guides/test-a-feature.md) 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 ```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](https://www.beignetjs.com/markdown/react-query.md) and [React Hook Form](https://www.beignetjs.com/markdown/react-hook-form.md) for the full adapter APIs. To build an API and UI together, follow the [Projects tutorial](https://www.beignetjs.com/markdown/learn.md).