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-queryreact-hook-formzodsonner- 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>
</>
);
}