Skip to content

Entity CRUD

Feature-based folder structure for list + create/edit sheet + mutations + cache invalidation.

What Problem This Solves

You need a repeatable pattern for managing any admin entity: listing with search/filter, row-level actions (view/edit/delete), create/edit in a responsive sheet/drawer, form validation, API mutations with cache invalidation, and toast feedback.

Prerequisites

  • @tanstack/react-query
  • react-hook-form
  • zod
  • sonner
  • shadcn/ui Sheet + Drawer + Table + Dialog + DropdownMenu (or your UI library)

Files

entity-action-menu.tsx

tsx
import { useNavigate } from "@tanstack/react-router";
import { Copy, Edit, Eye, MoreHorizontal, Trash } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "./alert-dialog";
import { Button } from "./button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "./dropdown-menu";

type EntityActionMenuProps<T extends { id: string }> = {
  entity: T;
  entityName: string;
  onEdit: (entity: T) => void;
  onDelete: (id: string) => void;
  viewRoute: string;
};

export function EntityActionMenu<T extends { id: string }>({
  entity,
  entityName,
  onEdit,
  onDelete,
  viewRoute,
}: EntityActionMenuProps<T>) {
  const navigate = useNavigate();
  const [showDeleteDialog, setShowDeleteDialog] = useState(false);

  return (
    <>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" size="icon">
            <MoreHorizontal size={16} />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem onClick={() => navigate({ to: viewRoute, params: { id: entity.id } })}>
            <Eye size={14} className="mr-2" /> View
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => onEdit(entity)}>
            <Edit size={14} className="mr-2" /> Edit
          </DropdownMenuItem>
          <DropdownMenuItem
            onClick={() => {
              navigator.clipboard.writeText(entity.id);
              toast.success("ID copied");
            }}
          >
            <Copy size={14} className="mr-2" /> Copy ID
          </DropdownMenuItem>
          <DropdownMenuItem onClick={() => setShowDeleteDialog(true)} className="text-red-600">
            <Trash size={14} className="mr-2" /> Delete
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>

      <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Delete {entityName}?</AlertDialogTitle>
            <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>Cancel</AlertDialogCancel>
            <AlertDialogAction onClick={() => onDelete(entity.id)}>Delete</AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </>
  );
}

responsive-dialog.tsx

Switches between Sheet (desktop) and Drawer (mobile) automatically.

tsx
import type * as React from "react";
import { useMediaQuery } from "./use-media-query";

type ResponsiveDialogProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  children: React.ReactNode;
  title: string;
  description: string;
  footer: React.ReactNode;
};

export function ResponsiveDialog({
  open,
  onOpenChange,
  children,
  title,
  description,
  footer,
}: ResponsiveDialogProps) {
  const isDesktop = useMediaQuery("(min-width: 640px)");

  if (isDesktop) {
    return (
      <Sheet open={open} onOpenChange={onOpenChange}>
        <SheetContent>
          <SheetHeader>
            <SheetTitle>{title}</SheetTitle>
            <SheetDescription>{description}</SheetDescription>
          </SheetHeader>
          {children}
          <SheetFooter>{footer}</SheetFooter>
        </SheetContent>
      </Sheet>
    );
  }

  return (
    <Drawer open={open} onOpenChange={onOpenChange}>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>{title}</DrawerTitle>
          <DrawerDescription>{description}</DrawerDescription>
        </DrawerHeader>
        {children}
        <DrawerFooter>{footer}</DrawerFooter>
      </DrawerContent>
    </Drawer>
  );
}

entity-sheet.tsx

tsx
import { ResponsiveDialog } from "./responsive-dialog";
import { Button } from "./button";

type EntitySheetProps = {
  children: React.ReactNode;
  formKey: string;
  onOpenChange: (open: boolean) => void;
  open: boolean;
  title: string;
  description?: string;
  footer?: React.ReactNode;
};

export function EntitySheet({
  children,
  formKey,
  onOpenChange,
  open,
  title,
  description,
  footer,
}: EntitySheetProps) {
  return (
    <ResponsiveDialog
      open={open}
      onOpenChange={onOpenChange}
      title={title}
      description={description ?? `Fill in the details below to ${title.toLowerCase()}.`}
      footer={
        footer ?? (
          <>
            <Button variant="outline" onClick={() => onOpenChange(false)}>
              Cancel
            </Button>
            <Button form={formKey} type="submit">
              {title}
            </Button>
          </>
        )
      }
    >
      <form id={formKey} className="space-y-4 px-4 py-4">
        {children}
      </form>
    </ResponsiveDialog>
  );
}

Usage

tsx
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { EntityActionMenu } from "./entity-action-menu";
import { EntitySheet } from "./entity-sheet";

export function OrdersPage() {
  const [editingOrder, setEditingOrder] = useState<Order | null>(null);
  const [isSheetOpen, setIsSheetOpen] = useState(false);
  const queryClient = useQueryClient();

  const { data: orders } = useQuery({ queryKey: ["orders"], queryFn: fetchOrders });

  const deleteMutation = useMutation({
    mutationFn: deleteOrder,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["orders"] });
      toast.success("Order deleted");
    },
  });

  const columns = [
    { key: "id", title: "ID" },
    { key: "customer", title: "Customer" },
    {
      key: "actions",
      render: (_: unknown, order: Order) => (
        <EntityActionMenu
          entity={order}
          entityName="Order"
          onEdit={(o) => {
            setEditingOrder(o);
            setIsSheetOpen(true);
          }}
          onDelete={(id) => deleteMutation.mutate(id)}
          viewRoute="/orders/$id"
        />
      ),
    },
  ];

  return (
    <>
      <Table dataSource={orders} columns={columns} />
      <EntitySheet
        open={isSheetOpen}
        onOpenChange={setIsSheetOpen}
        title={editingOrder ? "Edit Order" : "Create Order"}
        formKey="order-form"
      >
        {/* Your form fields here */}
      </EntitySheet>
    </>
  );
}