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
| File | Role |
|---|---|
components/ui/extended-form.tsx | useZodForm() and <ExtendedForm>: react-hook-form with a Zod resolver. |
components/ui/field.tsx | Field layout primitives: Field, FieldLabel, FieldContent, FieldError, FieldDescription… |
components/ui/submit-button.tsx | SubmitButton and LoadingButton, buttons with a loading spinner. |
lib/utils/validation-errors.ts | applyValidationErrors(): shows server validation errors under form fields. |
features/dialog-manager/ | The dialog manager: store, factory, types, renderer. |
components/ui/toast.tsx | The toast manager (toast) and the Toaster viewport. |
components/data-table/faceted-filter.tsx | DataTableFacetedFilter, a multi-select column filter. |
features/*/*-table.tsx | Data 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 })callsuseForm()withzodResolver(schema). Values are typed from the schema.<ExtendedForm form={form} onSubmit={...}>renders a<form>inside aFormProvider, callsform.handleSubmit(onSubmit), and wraps the children in a<fieldset>that is disabled while the form is submitting (or when you passdisabled).
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
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
const form = useZodForm({
schema,
defaultValues: {
name: props.initialValues?.name ?? "",
description: props.initialValues?.description ?? "",
},
})Wire the server action
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
<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.
| File | Role |
|---|---|
dialog-manager.ts | The public API: dialogManager.confirm, input, custom, close, closeAll. |
dialog-store.ts | The zustand store (useDialogStore) and handleDialogAction(). |
dialog-factory.ts | Builds dialog objects with a generated id. |
dialog-types.ts | Config types for each dialog type. |
dialog-component.tsx | Renders one dialog with AlertDialog. |
dialog-manager-renderer.tsx | Renders 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
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:
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:
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:
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:
| Option | Type | Description |
|---|---|---|
title | string | Dialog title. |
description | ReactNode | A string renders as the dialog description; any other node renders as is. |
icon | LucideIcon | Icon 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).
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:
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.
| Table | File |
|---|---|
| Users (admin) | features/admin/user-administration-table.tsx |
| Organizations (admin) | features/admin/organization-administration-table.tsx |
| Organization members | features/organization/organization-members.tsx |
| Generic resource table | features/resource-kit/resource-table.tsx |
| Feedback (admin) | features/admin/feedback-table.tsx |
| Categories, projects | features/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:
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.
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.