CRUD System

useCrudViewModel hook, GenericCrudView, DataTable, column helpers, form system, and dialog management.

The CRUD System provides a complete, reusable solution for building list/detail/form pages. At its core is the useCrudViewModel hook that manages pagination, search, sorting, selection, and CRUD mutations. GenericCrudView orchestrates the DataTable, form dialogs, and confirm dialogs.

Architecture Overview

Frontend CRUD Data Flow
View / GenericCrudView — Renders table, modals, input forms
binds UI state
useCrudViewModel — Coordinates operational state and actions
subscribes to
useGenericQuery — Fetch list using keepPreviousData cache preservation
useGenericMutations — Handles create, update, delete mutations
calls mutations
Repository Layer — Maps API DTO models to Domain Entities
uses IApiService
API Service / Axios — Executes HTTP requests and extracts errors

useCrudViewModel Hook

useCrudViewModel is a generic hook that encapsulates all CRUD-related state and operations. It accepts a configuration object defining data fetching, mutations, and UI options, and returns a comprehensive interface for building CRUD pages.

useCrudViewModel — Hook Signature & OptionsTypeScript
export interface CrudViewModelOptions {
  initialPageSize?: number; // Default: 10
  enabled?: boolean;         // Default: true
}

/**
 * Documentation for any>
 */
export function useCrudViewModel<T extends BaseEntity, TCreate = any, TUpdate = any>(
  key: any[],
  services: {
    getAll: (params: any) => Promise<PaginatedResult<T>>;
    create?: (data: TCreate) => Promise<T>;
    update?: (id: string, data: TUpdate) => Promise<T>;
    delete?: (id: string) => Promise<void>;
  },
  options: CrudViewModelOptions = {}
);
useCrudViewModel — Returned State & Actions InterfaceTypeScript
interface CrudViewModelReturn<T, TCreate, TUpdate> {
  // Data & Status
  items: T[];
  pagination: {
    itemsCount: number;
    pageSize: number;
    page: number;
    pagesCount: number;
  };
  loading: boolean;
  error: string | null;

  // Pagination State & Actions
  page: number;
  pageSize: number;
  changePage: (newPage: number) => void;
  changePageSize: (newSize: number) => void;

  // Search State & Actions
  searchValue: string;
  searchInputRef: React.RefObject<HTMLInputElement>;
  handleSearchChange: (term: string) => void;

  // Selection State & Actions
  selectedItems: string[];
  setSelectedItems: React.Dispatch<React.SetStateAction<string[]>>;

  // Modal Dialog Controllers
  isCreateModalOpen: boolean;
  setIsCreateModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
  isEditModalOpen: boolean;
  setIsEditModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
  viewModalOpen: boolean;
  setViewModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
  editingItem: T | null;
  viewItem: T | null;

  // Modal State Transitions
  openEditModal: (item: T) => void;
  closeEditModal: () => void;
  openViewModal: (item: T) => void;
  closeViewModal: () => void;
  refreshItems: () => Promise<void>;
  refresh: () => Promise<void>; // Alias for refreshItems

  // Mutation Wrappers
  createItem: (data: TCreate) => Promise<T>;
  updateItem: (id: string, data: TUpdate) => Promise<T>;
  deleteItem: (id: string) => Promise<void>;

  // Pending States
  isCreating: boolean;
  isUpdating: boolean;
  isDeleting: boolean;
}

Zero-Flicker Query Cache Preservation

To prevent loading flashes when page or search parameters change, useGenericQuery leverages TanStack Query's placeholderData: keepPreviousData. This keeps the existing grid visible during transition, ensuring a seamless user experience.

useGenericQuery.ts — Cache Preservation ConfigTypeScript
export function useGenericQuery<T, TParams = any>(
  key: any[],
  fetcher: (params: TParams) => Promise<PaginatedResult<T>>,
  params: TParams,
  enabled: boolean = true,
  options?: Omit<UseQueryOptions<PaginatedResult<T>>, "queryKey" | "queryFn">
) {
  return useQuery({
    queryKey: [...key, params],
    queryFn: () => fetcher(params),
    enabled,
    placeholderData: keepPreviousData, // Keeps the existing grid visible on page/search change
    ...options,
  });
}

Optimistic Deletes & Rollback Cache

When a deletion is triggered, the UI optimistically removes the item from the list cache and cancels active queries. If the backend API call fails, the mutation automatically rolls back the cache to its previous state and displays an error toast.

Optimistic Deletes & Rollback Sequence
User Delete Action — Clicks delete confirm dialog
onMutate Lifecycle Hook — Executes immediately before API request
Cancel Outgoing Queries — Prevents race conditions from active fetches
Capture Cache Snapshot — Saves previous list data to mutation context
Update Cache In-Memory — Filters out deleted record from UI state
Backend API Call — Executes HTTP DELETE query
API Call Result — Checks if backend request succeeded
Failure
Rollback Cache State — Restores saved list data snapshot on error
Settles cache
Re-fetch / Invalidate — Forces query invalidate to sync with backend
useGenericMutations.ts — Optimistic Delete SetupTypeScript
const deleteMutation = useMutation({
  mutationKey: [...(baseKey as unknown[]), "delete"],
  mutationFn: async (id: string) => services.delete(id),
  onMutate: options?.optimisticDelete
    ? async (id: string) => {
        await queryClient.cancelQueries({ queryKey: baseKey });
        const previous = queryClient.getQueryData(baseKey);
        
        // Optimistically remove from list cache
        queryClient.setQueryData(baseKey, (old: any) => {
          if (!old) return old;
          const items = old.items ?? old.data ?? [];
          const filtered = items.filter((item: any) => item.id !== id);
          return { ...old, items: filtered };
        });
        
        return { previous };
      }
    : undefined,
  onError: (_, __, context) => {
    if (context?.previous !== undefined) {
      // Rollback on failure
      queryClient.setQueryData(baseKey, context.previous);
    }
  },
  onSettled: () => {
    // Sync with database
    queryClient.invalidateQueries({ queryKey: baseKey });
  }
});

GenericCrudView Component

GenericCrudView is a pre-built component that orchestrates the DataTable, FormDialog, and ConfirmDialog. It accepts a crud ViewModel instance and column definitions, handling the entire CRUD UI with minimal code.

GenericCrudView — Usage ExampleTSX
<GenericCrudView
  crud={vm.table}
  columns={vm.columns}
  title={t("admins.title")}
  
  // Optional customization
  createButtonLabel={t("admins.create")}
  searchPlaceholder={t("admins.searchPlaceholder")}
  emptyMessage={t("admins.noData")}
  
  // Form configuration
  createForm={<AdminForm mode="create" />}
  editForm={<AdminForm mode="edit" item={vm.table.editingItem} />}
  
  // Slot components
  headerSlot={<FilterSection {...vm.filters} />}
  beforeTableSlot={<StatisticsSection {...vm.statistics} />}
/>

Column System

Column Helper API — All Builder MethodsTypeScript
const column = createColumnHelper<Admin>();

// Built-in column types:
column.index("No");                        // Auto-incrementing row number
column.text("firstName", t("name"));       // Simple text rendering
column.date("createdAt", t("date"));       // Formatted date (locale-aware)
column.date("createdAt", t("date"), {      // With custom format
  locale: "en-GB",
  format: "dd/MM/yyyy HH:mm"
});

column.status("isActive", t("status"), {   // Status badge with colors
  true:  { label: t("active"),   color: "success" },
  false: { label: t("inactive"), color: "danger" },
});

column.switch("isBlocked", t("block"), {    // Toggle switch
  getChecked: (row) => row.isBlocked,
  onChange: (row, checked) => vm.toggleBlock(row.id, checked),
  isLoading: vm.isBlockLoading,
});

column.link("email", t("email"), {          // Clickable link
  type: "email",                            // "email" | "phone" | "url"
});

column.image("avatarUrl", t("avatar"), {    // Image thumbnail
  width: 40, height: 40,
  fallback: "/default-avatar.png",
});

column.custom("actions", t("actions"), (row) => (  // Fully custom
  <ActionButtons
    onEdit={() => vm.table.openEditDialog(row)}
    onDelete={() => vm.table.openDeleteDialog(row)}
  />
));

DataTable Features

  • Global Search

    Debounced search across all text columns with configurable delay.

  • Column Sorting

    Click column headers to sort. Supports multi-directional sorting (asc/desc).

  • Pagination

    Server-side pagination with configurable page sizes (10/25/50/100).

  • Row Selection

    Checkbox selection with select-all, bulk actions, and exclusion mode.

  • Responsive Layout

    Responsive design with horizontal scrolling on mobile and priority columns.

  • RTL Support

    Full RTL layout support with automatic direction switching based on language.

Form System

The form system integrates GenericForm (Zod schema-driven) with FormDialog for create/edit operations, and ConfirmDialog for destructive actions. Forms support field validation, loading states, and error display.

GenericForm + FormDialog — UsageTSX
// GenericForm provides schema-driven form rendering
<GenericForm
  schema={adminSchema}
  defaultValues={editingAdmin}
  onSubmit={(data) => crud.update.mutate({ id: admin.id, data })}
  isLoading={crud.update.isPending}
  fields={[
    { name: "firstName", label: t("firstName"), type: "text" },
    { name: "email", label: t("email"), type: "email" },
    { name: "roleId", label: t("role"), type: "select",
      options: roles.map(r => ({ value: r.id, label: r.name })) },
    { name: "isActive", label: t("active"), type: "switch" },
  ]}
/>

// FormDialog wraps GenericForm in a dialog
<FormDialog
  open={crud.isEditDialogOpen}
  onClose={crud.closeDialogs}
  title={t("admins.editTitle")}
>
  <GenericForm ... />
</FormDialog>

// ConfirmDialog for destructive actions
<ConfirmDialog
  open={crud.isDeleteDialogOpen}
  onConfirm={() => crud.remove.mutate(crud.deletingItem?.id)}
  onCancel={crud.closeDialogs}
  title={t("admins.deleteTitle")}
  message={t("admins.deleteMessage", { name: crud.deletingItem?.firstName })}
  variant="danger"
  isLoading={crud.remove.isPending}
/>