Launch NowDocs

Forms and dialogs

Build forms with react-hook-form and Zod, open confirm, input and custom dialogs from anywhere, show toasts, and render data tables with TanStack Table.

Launch Now ships a small set of UI building blocks that the whole app reuses: a Zod-aware form wrapper, a global dialog manager, a toast manager and data tables. This guide shows how each one works and how to use it in your own features.

Where the code lives

FileRole
components/ui/extended-form.tsxuseZodForm() and <ExtendedForm>: react-hook-form with a Zod resolver.
components/ui/field.tsxField layout primitives: Field, FieldLabel, FieldContent, FieldError, FieldDescription
components/ui/submit-button.tsxSubmitButton and LoadingButton, buttons with a loading spinner.
lib/utils/validation-errors.tsapplyValidationErrors(): shows server validation errors under form fields.
features/dialog-manager/The dialog manager: store, factory, types, renderer.
components/ui/toast.tsxThe toast manager (toast) and the Toaster viewport.
components/data-table/faceted-filter.tsxDataTableFacetedFilter, a multi-select column filter.
features/*/*-table.tsxData tables built with @tanstack/react-table.

Forms

Forms use react-hook-form with a Zod schema as the resolver. Two helpers in components/ui/extended-form.tsx remove the boilerplate:

  • useZodForm({ schema, ...options }) calls useForm() with zodResolver(schema). Values are typed from the schema.
  • <ExtendedForm form={form} onSubmit={...}> renders a <form> inside a FormProvider, calls form.handleSubmit(onSubmit), and wraps the children in a <fieldset> that is disabled while the form is submitting (or when you pass disabled).

Build a form

The category form is a complete, small example. It validates on the client with Zod, submits to a server action, and shows server errors inline or in a toast.

Define the schema

features/categories/category-form-sheet.tsx
import z from "zod"
 
const schema = z.object({
  name: z.string().min(1, "Name is required").max(100),
  description: z.string().max(500).optional().or(z.literal("")),
})
 
type CategoryFormValues = z.infer<typeof schema>

Optional text inputs accept "" because an empty input submits an empty string. Convert it to undefined before calling the action.

Create the form

features/categories/category-form-sheet.tsx
const form = useZodForm({
  schema,
  defaultValues: {
    name: props.initialValues?.name ?? "",
    description: props.initialValues?.description ?? "",
  },
})

Wire the server action

features/categories/category-form-sheet.tsx
const { execute: executeCreate, status: createStatus } = useAction(
  createCategory,
  {
    onSuccess: () => {
      toast.add({ title: "Category created", type: "success" })
      form.reset()
      setOpen(false)
    },
    onError: ({ error }) => {
      if (applyValidationErrors(form.setError, error.validationErrors)) return
      toast.add({
        title: error.serverError ?? "Failed to create category",
        type: "error",
      })
    },
  }
)
 
function handleSubmit(values: CategoryFormValues) {
  executeCreate({
    name: values.name,
    description: values.description || undefined,
  })
}

applyValidationErrors(form.setError, validationErrors) walks next-safe-action's formatted errors and calls setError for every field that has a message. It returns true when it set at least one field error. Root-level errors are left to you, which is why the code falls back to a toast.

Render the fields

features/categories/category-form-sheet.tsx
<ExtendedForm form={form} onSubmit={handleSubmit} className="flex flex-1 flex-col">
  <Field>
    <FieldLabel htmlFor="category-name">Name</FieldLabel>
    <FieldContent>
      <Input
        id="category-name"
        placeholder="Category name"
        {...form.register("name")}
      />
      <FieldError>{form.formState.errors.name?.message}</FieldError>
    </FieldContent>
  </Field>
 
  <SubmitButton type="submit" className="w-full" loading={isSubmitting}>
    Create category
  </SubmitButton>
</ExtendedForm>

Native inputs (Input, Textarea, NativeSelect) work with form.register(). For controlled components such as Checkbox, read the value with form.watch(name) and write it with form.setValue(name, value, { shouldDirty: true, shouldValidate: true }), as features/resource-kit/resource-field-input.tsx does.

Submit buttons

SubmitButton shows a spinner while loading is true. Without the loading prop, it falls back to React's useFormStatus().pending, which only tracks native form actions; pass loading explicitly when you submit through useAction or react-hook-form. LoadingButton is the same button without the form status fallback, for buttons outside a form.

const isSubmitting =
  createStatus === "executing" || updateStatus === "executing"
 
<SubmitButton type="submit" loading={isSubmitting}>Save changes</SubmitButton>

Forms in a sheet

Create and edit forms open in a side sheet (components/ui/sheet.tsx). The same component handles both modes with a mode: "create" | "edit" prop and optional initialValues, and accepts a custom trigger. See features/categories/category-form-sheet.tsx and features/projects/project-form-sheet.tsx.

Dialog manager

The dialog manager lets any client code open a dialog with a function call, without adding dialog state or JSX to the component. It is a zustand store plus a single renderer.

FileRole
dialog-manager.tsThe public API: dialogManager.confirm, input, custom, close, closeAll.
dialog-store.tsThe zustand store (useDialogStore) and handleDialogAction().
dialog-factory.tsBuilds dialog objects with a generated id.
dialog-types.tsConfig types for each dialog type.
dialog-component.tsxRenders one dialog with AlertDialog.
dialog-manager-renderer.tsxRenders the active dialog. Mounted once in lib/providers.tsx.

Dialogs are queued: the store keeps a list and renders the first one. When it closes, the next one appears.

Confirm dialog

features/resource-kit/resource-delete-dialog.tsx
import { dialogManager } from "@/features/dialog-manager/dialog-manager"
 
dialogManager.confirm({
  title: `Delete ${title}`,
  description: `Are you sure you want to delete this ${title.toLowerCase()}? This action cannot be undone.`,
  style: "centered",
  action: {
    label: "Delete",
    variant: "destructive",
    onClick: () => onDelete(id),
  },
})

action.onClick can be async. While it runs, the action button shows a spinner and both buttons are disabled. When it resolves, the dialog closes. When it throws, the dialog stays open and an Action failed toast appears.

Add confirmText to require the user to type a word before the action button is enabled:

app/(app)/(dash)/demo/demo-content.tsx
dialogManager.confirm({
  title: "Delete workspace",
  description:
    "This will permanently delete your entire workspace and all associated data.",
  confirmText: "DELETE",
  style: "centered",
  action: {
    label: "Delete workspace",
    variant: "destructive",
    onClick: () => {
      // ...
    },
  },
})

Input dialog

dialogManager.input shows one text input. The action receives its value, and Enter submits:

app/(app)/(dash)/demo/demo-content.tsx
dialogManager.input({
  title: "Rename workspace",
  style: "centered",
  input: {
    label: "Workspace name",
    defaultValue: "My Workspace",
    placeholder: "Enter workspace name",
  },
  action: {
    label: "Save",
    onClick: (value) => {
      toast.add({ title: `Workspace renamed to "${value}".`, type: "success" })
    },
  },
})

Custom dialog

dialogManager.custom renders any React content. It returns the dialog id, which you use to close it:

features/account/security-settings.tsx
function openTwoFactorSetup() {
  const id = dialogManager.custom({
    size: "lg",
    children: (
      <TwoFactorSetup
        onComplete={() => {
          dialogManager.close(id)
          refetch()
        }}
        onCancel={() => dialogManager.close(id)}
      />
    ),
  })
}

dialogManager.closeAll() empties the queue.

Options

These options apply to every dialog type:

OptionTypeDescription
titlestringDialog title.
descriptionReactNodeA string renders as the dialog description; any other node renders as is.
iconLucideIconIcon shown in a circle above the title.
size"sm" | "md" | "lg"Dialog width.
style"default" | "centered"centered centers the header.

Confirm and input dialogs also take action (label, onClick, variant: "default" | "destructive") and an optional cancel (label, onClick).

If you pass cancel.onClick, the dialog no longer closes by itself on cancel or Escape: your handler replaces the default behavior. Call dialogManager.close(id) in it.

Toasts

Toasts use the Base UI toast manager in components/ui/toast.tsx. The <Toaster /> viewport is mounted in lib/providers.tsx, so you only import toast and call toast.add():

import { toast } from "@/components/ui/toast"
 
toast.add({ title: "Category created", type: "success" })
 
toast.add({
  title: error.serverError ?? "Failed to delete category",
  type: "error",
})

Pass a description for a second line:

features/status/subscribe-dialog.tsx
toast.add({
  // ...
  description: `We'll email ${email} when an incident starts, changes or is resolved.`,
})

The type picks the icon: success, info, warning, error or loading. Toasts stack in the bottom right corner and can be swiped away.

Good to know: components/ui/sonner.tsx is also present, but the app doesn't mount it. Use toast from components/ui/toast so your toasts share the same viewport.

Data tables

Tables are built with TanStack Table v9 and the table primitives in components/ui/table.tsx. Each table is a feature component: it defines its columns, toolbar, filters, pagination and row actions.

TableFile
Users (admin)features/admin/user-administration-table.tsx
Organizations (admin)features/admin/organization-administration-table.tsx
Organization membersfeatures/organization/organization-members.tsx
Generic resource tablefeatures/resource-kit/resource-table.tsx
Feedback (admin)features/admin/feedback-table.tsx
Categories, projectsfeatures/categories/categories-table.tsx, features/projects/projects-table.tsx

The first four use the v9 useTable API. The last three use the compatibility API from @tanstack/react-table/legacy (useLegacyTable). Start new tables from the v9 ones.

Set up a table

Declare the features the table needs once, outside the component, then pass them to useTable with the columns and data. The second argument selects the state the component re-renders on:

features/admin/user-administration-table.tsx
import {
  type ColumnDef,
  columnFacetingFeature,
  columnFilteringFeature,
  columnSizingFeature,
  columnVisibilityFeature,
  createFacetedUniqueValues,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  rowPaginationFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_datetime,
  sortFn_text,
  tableFeatures,
  useTable,
} from "@tanstack/react-table"
 
const features = tableFeatures({
  columnFilteringFeature,
  columnFacetingFeature,
  columnSizingFeature,
  columnVisibilityFeature,
  facetedUniqueValues: createFacetedUniqueValues(),
  filteredRowModel: createFilteredRowModel(),
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: {
    alphanumeric: sortFn_alphanumeric,
    datetime: sortFn_datetime,
    text: sortFn_text,
  },
})
 
// Inside the component
const columns = useMemo<ColumnDef<typeof features, UserRow>[]>(() => [
  // ...
], [])
 
const table = useTable(
  {
    columns,
    data,
    features,
    getRowId: (row) => row.id,
    enableSortingRemoval: false,
    initialState: {
      pagination: { pageIndex: 0, pageSize: 10 },
      sorting: [{ id: "createdAt", desc: true }],
    },
  },
  (state) => ({
    columnFilters: state.columnFilters,
    columnVisibility: state.columnVisibility,
    pagination: state.pagination,
    sorting: state.sorting,
  })
)

Filtering, sorting and pagination all run in the browser on the rows you pass in. Read the current state from table.state, for example table.state.pagination.

Faceted filters

DataTableFacetedFilter is a popover checklist with counts. It is table-agnostic: wire it to a column's filter value and faceted counts.

features/admin/user-administration-table.tsx
import { DataTableFacetedFilter } from "@/components/data-table/faceted-filter"
 
const roleColumn = table.getColumn("role")
const selectedRoles = (roleColumn?.getFilterValue() as string[]) ?? []
const roleCounts = roleColumn?.getFacetedUniqueValues() ?? new Map()
 
<DataTableFacetedFilter
  title="Role"
  options={[
    { value: "admin", label: "Admin" },
    { value: "user", label: "User" },
  ].map((option) => ({
    ...option,
    count: roleCounts.get(option.value) ?? 0,
  }))}
  selected={selectedRoles}
  onChange={(next) =>
    roleColumn?.setFilterValue(next.length ? next : undefined)
  }
/>

Set the filter to undefined when nothing is selected so the column stops filtering. The column needs a filter function that accepts an array of values.

A table without writing one

For a standard CRUD list, you don't need to write a table. ResourceTable in features/resource-kit/resource-table.tsx renders search, faceted filters for select fields, column visibility, sorting, pagination, row selection with bulk delete, and edit and delete row actions from a list of field definitions. The resource generator wires it up for you.

Next steps