Recipes

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 BulkActions component 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

#FileWhat to do
1actions.tsAdd bulk action function (SQL with inArray)
2RouterAdd bulk endpoint with ids array input
3List page — columnsAdd createSelectionColumn() as first column
4List page — mutationsCreate mutation with toast feedback
5List page — toolbarRender 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 typecheck

On this page