shadcn Form and Data Table, Wired Together

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.

Packages
SideInstall
Formreact-hook-form, zod, @hookform/resolvers
Table@tanstack/react-table
UInpx 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.

How the pieces fit

Who does what
JobHandled by
Field layout, labels, error stylingshadcn Field components
Form state, submit, focus on first errorReact Hook Form
Rules and messages, shared with the serverZod schema
Table lookshadcn Table components
Sorting, search, pagesTanStack Table
SavingA server action or API route, validating again

The form

Install the pieces

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-table

Booking form: shadcn Field + React Hook Form + Zod

Controller 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>
  );
}

Saving with a server action

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

The data table

Data table: shadcn Table + TanStack Table

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

Going further

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.

Common mistakes

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.

How it works

  1. Add the shadcn components and install the three form packages and TanStack Table.
  2. Write one Zod schema for the form and import it on the server too.
  3. Render each field through Controller inside a shadcn Field.
  4. Save with a server action that validates again and returns field errors.
  5. Show saved rows in the shadcn Table driven by TanStack Table.

Tips

Frequently asked questions

Should I use the Form component or the Field component?

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.

Do I need TanStack Table for a shadcn table?

No. The shadcn Table components are just styled table elements. Add TanStack Table when you need sorting, filtering, pagination or selection.

How do I show server errors under the right field?

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.

Can the table refresh after the form saves?

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.

More in Guides

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.

Product

Who It's For

Features

Resources

Company

View Sitemap