--- 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 (
); } ``` 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 ( ); } ``` `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 (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."}
{offset === 0 ? "No todos yet. Create your first one above." : "No todos on this page. Go back to the previous page."}
) : (Refreshing todos…
} > )}+ {signOutError} +
+ )}{user.name}
@@ -89,6 +112,7 @@ variant="ghost" size="icon" aria-label="Sign out" + disabled={signingOut} onClick={signOut} >