A shadcn/ui form pairs the Field components with React Hook Form's Controller and a Zod schema passed through zodResolver. A shadcn data table pairs the Table components with TanStack Table for sorting, search and pages. Below, a booking form adds rows to a bookings table live, with the code for both.
| Side | Install |
|---|---|
| Form | react-hook-form, zod, @hookform/resolvers |
| Table | @tanstack/react-table |
| UI | npx shadcn@latest add field input table button |
Updated
Submit the form with a mistake to see the errors, then book a table and watch it appear in the list underneath. The preview uses our site's copies of the shadcn parts and plain state for the table; the code is the full shadcn, React Hook Form, Zod and TanStack version.
Want more composed blocks first? See shadcn/ui examples.
| Job | Handled by |
|---|---|
| Field layout, labels, error styling | shadcn Field components |
| Form state, submit, focus on first error | React Hook Form |
| Rules and messages, shared with the server | Zod schema |
| Table look | shadcn Table components |
| Sorting, search, pages | TanStack Table |
| Saving | A server action or API route, validating again |
The form side needs React Hook Form, Zod and the resolver; the table side needs TanStack Table. The shadcn CLI copies in the UI parts.
npx shadcn@latest add field input textarea select button table checkbox
npm install react-hook-form zod @hookform/resolvers @tanstack/react-tableController connects each input. data-invalid on Field styles the whole row, aria-invalid on the input tells screen readers, and FieldError prints the Zod message.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
const schema = z.object({
name: z.string().min(2, "Tell us your name"),
email: z.string().email("Enter a valid email"),
guests: z.coerce.number().int().min(1, "At least 1").max(12, "Call us for groups over 12"),
notes: z.string().max(300, "Keep it under 300 characters").optional(),
});
type Booking = z.infer<typeof schema>;
export function BookingForm({ onBooked }: { onBooked: (b: Booking) => Promise<void> }) {
const form = useForm<Booking>({
resolver: zodResolver(schema),
defaultValues: { name: "", email: "", guests: 2, notes: "" },
});
return (
<form onSubmit={form.handleSubmit(onBooked)} noValidate>
<FieldGroup>
{(["name", "email", "guests"] as const).map((name) => (
<Controller key={name} name={name} control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>{name[0].toUpperCase() + name.slice(1)}</FieldLabel>
<Input {...field} id={field.name} type={name === "guests" ? "number" : name === "email" ? "email" : "text"}
aria-invalid={fieldState.invalid} />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)} />
))}
<Controller name="notes" control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="notes">Notes</FieldLabel>
<Textarea {...field} id="notes" aria-invalid={fieldState.invalid} />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)} />
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Booking..." : "Book a table"}
</Button>
</FieldGroup>
</form>
);
}Run the same schema again on the server. Return field errors and put them back on the form with setError.
// actions.ts
"use server";
export async function createBooking(input: unknown) {
const parsed = schema.safeParse(input);
if (!parsed.success) return { ok: false as const, errors: parsed.error.flatten().fieldErrors };
if (await fullyBooked(parsed.data.guests)) return { ok: false as const, errors: { guests: ["No table that size tonight"] } };
await db.booking.create({ data: parsed.data });
return { ok: true as const };
}
// in the form
const onBooked = async (values: Booking) => {
const res = await createBooking(values);
if (!res.ok) {
for (const [field, msgs] of Object.entries(res.errors)) {
form.setError(field as keyof Booking, { message: msgs?.[0] });
}
return;
}
form.reset();
};shadcn's Table components are styled table elements; TanStack supplies sorting, filtering and pages. The features object and columns live outside the component so they stay stable.
"use client";
import {
columnFilteringFeature, createColumnHelper, createFilteredRowModel, createPaginatedRowModel,
createSortedRowModel, filterFn_includesString, globalFilteringFeature, rowPaginationFeature,
rowSortingFeature, sortFn_alphanumeric, tableFeatures, useTable,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
type Booking = { id: string; name: string; guests: number; time: string };
const features = tableFeatures({
rowSortingFeature, sortedRowModel: createSortedRowModel(), sortFns: { alphanumeric: sortFn_alphanumeric },
columnFilteringFeature, globalFilteringFeature, filteredRowModel: createFilteredRowModel(),
filterFns: { includesString: filterFn_includesString },
rowPaginationFeature, paginatedRowModel: createPaginatedRowModel(),
});
const h = createColumnHelper<typeof features, Booking>();
const columns = h.columns([
h.accessor("name", { header: "Name", sortFn: "alphanumeric" }),
h.accessor("guests", { header: "Guests" }),
h.accessor("time", { header: "Time" }),
]);
export function BookingsTable({ data }: { data: Booking[] }) {
const table = useTable({
features, columns, data,
getRowId: (r) => r.id,
globalFilterFn: "includesString",
initialState: { pagination: { pageIndex: 0, pageSize: 5 } },
});
return (
<div className="space-y-3">
<Input placeholder="Search bookings" aria-label="Search bookings" className="max-w-xs"
value={String(table.state.globalFilter ?? "")} onChange={(e) => table.setGlobalFilter(e.target.value)} />
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((g) => (
<TableRow key={g.id}>
{g.headers.map((hd) => (
<TableHead key={hd.id}>
<Button variant="ghost" size="sm" onClick={hd.column.getToggleSortingHandler()}>
<table.FlexRender header={hd} />
{hd.column.getIsSorted() === "asc" ? " ▲" : hd.column.getIsSorted() === "desc" ? " ▼" : ""}
</Button>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getAllCells().map((cell) => <TableCell key={cell.id}><table.FlexRender cell={cell} /></TableCell>)}
</TableRow>
)) : (
<TableRow><TableCell colSpan={columns.length} className="h-24 text-center">No results.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</Button>
</div>
</div>
);
}
For more React Hook Form patterns, such as field arrays, wizards and file uploads, see our React Hook Form examples. For column visibility, row selection, sticky headers and server-side paging in TanStack Table v9, see our React data table guide.
Spreading field onto a shadcn Select, which expects value and onValueChange rather than onChange. Map them by hand inside the Controller.
Rebuilding the TanStack features object inside the component, which resets sorting and paging on every render.
The current shadcn/ui forms guide builds forms from Field, FieldLabel and FieldError with React Hook Form's Controller. Older projects use the Form and FormField wrappers; both rely on React Hook Form, so existing forms keep working.
No. The shadcn Table components are just styled table elements. Add TanStack Table when you need sorting, filtering, pagination or selection.
Have the server action return errors keyed by field name, then call form.setError(field, { message }) for each one. FieldError shows them like any other validation message.
Yes. In Next.js, call revalidatePath in the server action so the page that renders the table fetches fresh rows. In a client-only app, add the new row to your data state or refetch.
Related: shadcn/ui examples, React Hook Form examples, React data table examples, Booking form generator, All free tools.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.