Add Bulk Action
How to add bulk delete, update, or custom actions to a DataTable
Add Bulk Action
TL;DR: Add selection column, create bulk endpoint, wire up
BulkActionscomponent in toolbar.
What You Get
When users select rows in a DataTable, a dropdown appears in the toolbar with actions like "Delete selected" or "Change status". Each action can show a confirmation dialog before executing.
┌──────────────────────────────────────────────────────────────┐
│ [Search...] [Status ▼] Selected: 3 [Actions ▼] │
╞══════════════════════════════════════════════════════════════╡
│ ☑ │ Acme Inc │ Active │ Tech │ │
│ ☐ │ Globex Corp │ Lead │ Finance │ │
│ ☑ │ Initech │ Active │ Tech │ │
│ ☑ │ Umbrella Co │ Inactive │ Pharma │ │
╞══════════════════════════════════════════════════════════════╡
│ Page 1 of 5 [< 1 2 3 ... >] │
└──────────────────────────────────────────────────────────────┘The Checklist
| # | File | What to do |
|---|---|---|
| 1 | actions.ts | Add bulk action function (SQL with inArray) |
| 2 | Router | Add bulk endpoint with ids array input |
| 3 | List page — columns | Add createSelectionColumn() as first column |
| 4 | List page — mutations | Create mutation with toast feedback |
| 5 | List page — toolbar | Render BulkActions in bulkActionsSlot |
Step 1: Create Bulk Action
// src/features/tasks/actions.ts
import { inArray, and, eq } from "drizzle-orm"
export async function deleteTasksBulk(params: {
organizationId: string
ids: string[]
}): Promise<{ deletedCount: number }> {
if (params.ids.length === 0) {
return { deletedCount: 0 }
}
const deleted = await db
.delete(tasks)
.where(
and(
eq(tasks.organizationId, params.organizationId),
inArray(tasks.id, params.ids)
)
)
.returning({ id: tasks.id })
return { deletedCount: deleted.length }
}For bulk update (e.g. change status):
export async function updateTasksStatusBulk(params: {
organizationId: string
ids: string[]
statusId: string
}): Promise<{ updatedCount: number }> {
if (params.ids.length === 0) {
return { updatedCount: 0 }
}
const updated = await db
.update(tasks)
.set({ statusId: params.statusId, updatedAt: new Date() })
.where(
and(
eq(tasks.organizationId, params.organizationId),
inArray(tasks.id, params.ids)
)
)
.returning({ id: tasks.id })
return { updatedCount: updated.length }
}Key pattern: Always filter by organizationId from context + inArray(column, ids). Return count of affected rows.
Step 2: Add Router Endpoint
// src/server/routers/tasks.ts
export const tasksRouter = {
// ... existing endpoints
bulkDelete: authedProcedure
.input(z.object({
ids: z.array(z.string().uuid()).min(1).max(100),
}))
.handler(async ({ input, context }) => {
return deleteTasksBulk({
organizationId: context.organizationId,
ids: input.ids,
})
}),
bulkUpdateStatus: authedProcedure
.input(z.object({
ids: z.array(z.string().uuid()).min(1).max(100),
statusId: z.string().uuid(),
}))
.handler(async ({ input, context }) => {
return updateTasksStatusBulk({
organizationId: context.organizationId,
ids: input.ids,
statusId: input.statusId,
})
}),
}Limit array size with .max(100) to prevent abuse.
Step 3: Add Selection Column
// In your columns definition
import { createSelectionColumn } from "@/components/data-table"
const columns: ColumnDef<TaskListItem>[] = [
createSelectionColumn<TaskListItem>(), // ← First column, adds checkboxes
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
},
// ... other columns
]This adds a checkbox column with select-all in the header (selects current page).
Step 4: Create Mutations
// In your list page component
const queryClient = useQueryClient()
const { toast } = useToast()
const bulkDeleteMutation = useMutation({
...orpcUtils.tasks.bulkDelete.mutationOptions(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: orpcUtils.tasks.key() })
toast({
title: t("deleted"),
description: t("bulkDeleteSuccess", { count: data.deletedCount }),
})
},
onError: (error: Error) => {
toast({
title: t("common.error"),
description: error.message,
variant: "destructive",
})
},
})Step 5: Render BulkActions in Toolbar
import { BulkActions } from "@/components/data-table"
import { Trash2 } from "lucide-react"
<DataTableToolbar
table={table}
searchValue={q}
onSearchChange={onSearchChange}
bulkActionsSlot={
<BulkActions
table={table}
actions={[
{
label: t("bulkDelete"),
icon: Trash2,
variant: "destructive",
confirm: t("bulkDeleteConfirm", { count: "{count}" }),
onAction: async (rows) => {
await bulkDeleteMutation.mutateAsync({
ids: rows.map((r) => r.original.id),
})
},
},
]}
/>
}
/>That's all. When rows are selected, a badge shows "Selected: N" and the Actions dropdown appears.
Action Configuration
Each action in the actions array accepts:
{
label: string // Button text
icon?: LucideIcon // Icon next to label
variant?: "default" | "destructive" // Red for destructive actions
confirm?: string // Confirmation message ({count} is replaced)
confirmContent?: (rows) => ReactNode // Extra content inside confirm dialog
onAction: (rows) => Promise<void> // Called after confirmation
disabled?: boolean | ((rows) => boolean) // Conditionally disable
}Confirmation with Item Preview
Show users exactly what they're about to delete:
{
label: "Delete",
icon: Trash2,
variant: "destructive",
confirm: "Delete {count} task(s)? This cannot be undone.",
confirmContent: (rows) => (
<ul className="max-h-[200px] overflow-y-auto rounded border p-2 text-sm">
{rows.map((r) => (
<li key={r.original.id}>{r.original.name}</li>
))}
</ul>
),
onAction: async (rows) => {
await bulkDeleteMutation.mutateAsync({
ids: rows.map((r) => r.original.id),
})
},
}Bulk Update with Secondary Input
For actions that need extra input (pick a status, assign to user), open a custom dialog:
const [statusDialogOpen, setStatusDialogOpen] = useState(false)
const [selectedRows, setSelectedRows] = useState<Row<TaskListItem>[]>([])
// In BulkActions:
{
label: "Change status",
icon: RefreshCw,
onAction: async (rows) => {
setSelectedRows(rows)
setStatusDialogOpen(true)
},
}
// Separate dialog component:
<StatusPickerDialog
open={statusDialogOpen}
onOpenChange={setStatusDialogOpen}
onConfirm={async (statusId) => {
await bulkUpdateStatusMutation.mutateAsync({
ids: selectedRows.map((r) => r.original.id),
statusId,
})
setStatusDialogOpen(false)
}}
/>Common Mistakes
Don't forget organizationId in bulk operations:
// ❌ Wrong — no tenant isolation
await db.delete(tasks).where(inArray(tasks.id, ids))
// ✅ Correct — always scope to organization
await db.delete(tasks).where(
and(eq(tasks.organizationId, organizationId), inArray(tasks.id, ids))
)Don't send empty arrays:
// ❌ Wrong — SQL error on empty IN clause
await db.delete(tasks).where(inArray(tasks.id, []))
// ✅ Correct — guard at the top
if (ids.length === 0) return { deletedCount: 0 }Don't forget to invalidate related queries:
// If deleting contacts, also invalidate parent client queries
// (client's contactsCount needs to update)
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: orpcUtils.tasks.key() })
queryClient.invalidateQueries({ queryKey: orpcUtils.projects.key() }) // Parent
}Verify
npm run typecheckRelated
- Add New Feature — Set up the list page first
- Add Filter — Filter before bulk actions