npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bomjan133/react-crud-ui

v1.2.16

Published

A comprehensive React CRUD and UI component library with Zustand store integration

Readme

@bomjan133/react-crud-ui

A comprehensive React CRUD and UI component library with Zustand store integration, layout components, and CLI code generator.

Features

  • 🎨 20+ UI Components - Button, Card, Badge, Dialog, Select, Tabs, Table, and more
  • 📊 CRUD Components - DataTable, CrudList, CrudForm, CrudDetail with store integration
  • 🔄 Zustand Store Factory - Create CRUD stores with minimal code
  • 🎯 Form Components - QInput, QSelect, QSwitch, QSearchSelect with validation
  • 🏗️ Layout Components - AuthLayout, LoginPage, DashboardLayout with Sidebar & Header
  • 📱 Responsive - Mobile-first design with Tailwind CSS v4
  • 🌙 Dark Mode - Built-in dark mode support via CSS variables
  • ⚡ TypeScript Ready - Full TypeScript support
  • 🛠️ CLI Generator - Generate CRUD modules with one command

Documentation

The full component/API reference ships inside the package (node_modules/@bomjan133/react-crud-ui/docs/) and is also inlined below as collapsible sections, so you can read it directly on this page (npmjs.com or GitLab) without leaving it.

A comprehensive React CRUD and UI component library with Zustand store integration.

Table of Contents

  1. Installation
  2. Quick Setup
  3. Project Structure
  4. Creating Your First CRUD Module

Installation

npm install @bomjan133/react-crud-ui
# or
yarn add @bomjan133/react-crud-ui
# or
pnpm add @bomjan133/react-crud-ui

Peer Dependencies

Make sure you have these peer dependencies installed:

npm install react react-dom react-router-dom zustand axios

Quick Setup

1. Import Styles

Add the styles to your main entry file (e.g., main.jsx or App.jsx):

import '@bomjan133/react-crud-ui/styles.css'

2. Configure Tailwind v4 (Optional)

If you're using Tailwind CSS v4 in your project:

index.css:

@import "tailwindcss";

@theme {
  /* Light theme - Clean white/gray palette */
  --color-background: #ffffff;
  --color-foreground: #09090b;
  --color-card: #ffffff;
  --color-card-foreground: #09090b;
  --color-popover: #ffffff;
  --color-popover-foreground: #09090b;
  --color-primary: #000000;
  --color-primary-foreground: #fef2f2;
  --color-secondary: #f4f4f5;
  --color-secondary-foreground: #18181b;
  --color-muted: #f4f4f5;
  --color-muted-foreground: #71717a;
  --color-accent: #f4f4f5;
  --color-accent-foreground: #18181b;
  --color-destructive: #ef4444;
  --color-destructive-foreground: #fafafa;
  --color-border: #e4e4e7;
  --color-input: #e4e4e7;
  --color-ring: #000000;

  /* Sidebar specific */
  --color-sidebar: #fafafa;
  --color-sidebar-foreground: #3f3f46;
  --color-sidebar-primary: #000000;
  --color-sidebar-primary-foreground: #fafafa;
  --color-sidebar-accent: #f4f4f5;
  --color-sidebar-accent-foreground: #18181b;
  --color-sidebar-border: #e5e7eb;

  /* Border radius */
  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
}

3. Set up API Client

Create an axios client for API calls:

src/lib/apiClient.js:

import axios from 'axios';

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1';

const apiClient = axios.create({
  baseURL: API_BASE_URL,
  headers: { 'Content-Type': 'application/json' },
});

// Add auth token to requests
apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem('auth_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle auth errors
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('auth_token');
      window.location.href = '/login';
    }
    return Promise.reject({
      message: error.response?.data?.error || error.message,
      status: error.response?.status,
      data: error.response?.data,
    });
  }
);

export default apiClient;

Project Structure

Recommended project structure when using this package:

src/
├── App.jsx                 # Main app component
├── main.jsx                # Entry point
├── index.css               # Global styles (Tailwind)
│
├── pages/                  # Page components (by module)
│   ├── auth/
│   │   └── LoginPage.jsx
│   ├── dashboard/
│   │   └── DashboardPage.jsx
│   ├── users/
│   │   ├── UserListPage.jsx
│   │   ├── UserFormPage.jsx
│   │   └── UserDetailPage.jsx
│   └── products/
│       ├── ProductListPage.jsx
│       ├── ProductFormPage.jsx
│       └── ProductDetailPage.jsx
│
├── routes/
│   ├── index.jsx           # Main router
│   ├── guards.jsx          # Protected/Public route guards
│   └── modules/
│       ├── userRoutes.js
│       └── productRoutes.js
│
├── stores/
│   ├── createCrudStore.js  # Import from package or customize
│   ├── useAuthStore.js     # Auth state
│   ├── useUIStore.js       # UI state (toasts, modals, sidebar)
│   ├── useUserStore.js
│   └── useProductStore.js
│
├── components/
│   └── layouts/
│       ├── MainLayout.jsx  # Uses DashboardLayout from package
│       └── AuthLayout.jsx  # Uses AuthLayout from package
│
├── hooks/
│   └── useUI.js            # Toast, modal, sidebar hooks
│
└── lib/
    ├── apiClient.js        # Axios instance
    └── utils.js            # Utility functions

Creating Your First CRUD Module

Method 1: Using the CLI (Recommended)

npx @bomjan133/react-crud-ui generate product

This creates:

  • src/pages/products/ProductListPage.jsx
  • src/pages/products/ProductFormPage.jsx
  • src/pages/products/ProductDetailPage.jsx
  • src/routes/modules/productRoutes.js
  • src/stores/useProductStore.js

Method 2: Manual Setup

1. Create the Store:

// src/stores/useProductStore.js
import { createCrudStore } from '@bomjan133/react-crud-ui';

const defaultFormValues = {
  name: '',
  description: '',
  price: 0,
  is_active: true,
};

const useProductStore = createCrudStore('product', '/products/', defaultFormValues);

export default useProductStore;

2. Create the List Page:

// src/pages/products/ProductListPage.jsx
import { CrudList, Badge } from '@bomjan133/react-crud-ui';
import useProductStore from '@/stores/useProductStore';

const columns = [
  { key: 'name', label: 'Name', sortable: true },
  { key: 'price', label: 'Price', render: (v) => `$${v}` },
  {
    key: 'is_active',
    label: 'Status',
    render: (v) => <Badge variant={v ? 'success' : 'secondary'}>{v ? 'Active' : 'Inactive'}</Badge>,
  },
];

export default function ProductListPage() {
  const store = useProductStore();

  return (
    <CrudList
      title="Products"
      description="Manage products"
      createPath="/products/create"
      editPath="/products"
      store={store}
      columns={columns}
      searchPlaceholder="Search products..."
      getDeleteItemName={(item) => item?.name}
    />
  );
}

3. Create the Form Page:

// src/pages/products/ProductFormPage.jsx
import { Box } from 'lucide-react';
import { CrudForm, QInput, QTextarea, QSwitch } from '@bomjan133/react-crud-ui';
import useProductStore from '@/stores/useProductStore';

export default function ProductFormPage() {
  const { form, setFormField } = useProductStore();

  return (
    <CrudForm
      title="Product"
      description="Enter product information"
      icon={Box}
      listPath="/products"
      useStore={useProductStore}
    >
      <QInput
        name="name"
        label="Name"
        value={form.values.name || ''}
        onChange={(val) => setFormField('name', val)}
        error={form.errors.name}
        placeholder="Enter name"
        required
      />
      <QTextarea
        name="description"
        label="Description"
        value={form.values.description || ''}
        onChange={(val) => setFormField('description', val)}
        rows={4}
      />
      <QInput
        name="price"
        label="Price"
        type="number"
        value={form.values.price || 0}
        onChange={(val) => setFormField('price', parseFloat(val))}
      />
      <QSwitch
        label="Active"
        checked={form.values.is_active ?? true}
        onChange={(val) => setFormField('is_active', val)}
      />
    </CrudForm>
  );
}

4. Create Routes:

// src/routes/modules/productRoutes.js
import ProductListPage from '@/pages/products/ProductListPage';
import ProductFormPage from '@/pages/products/ProductFormPage';

const productRoutes = [
  { path: '/products', element: <ProductListPage /> },
  { path: '/products/create', element: <ProductFormPage /> },
  { path: '/products/:id/edit', element: <ProductFormPage /> },
];

export default productRoutes;

Next Steps

Complete reference for all UI components included in @bomjan133/react-crud-ui.

Table of Contents

  1. Button
  2. Card
  3. Badge
  4. Input & Label
  5. Select
  6. Checkbox & Switch
  7. Dialog
  8. Dropdown Menu
  9. Tabs
  10. Table
  11. Avatar
  12. Tooltip
  13. Date Picker
  14. Rich Text Editor
  15. Separator
  16. Skeleton
  17. Alert

Button

import { Button, buttonVariants } from '@bomjan133/react-crud-ui';

// Variants
<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button variant="success">Success</Button>

// Sizes
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon"><Icon /></Button>

// With icon
<Button>
  <Plus className="h-4 w-4 mr-2" />
  Add Item
</Button>

// Loading state
<Button disabled>
  <Loader2 className="h-4 w-4 mr-2 animate-spin" />
  Loading...
</Button>

// As link using buttonVariants
<a href="/path" className={buttonVariants({ variant: "outline" })}>
  Link Button
</a>

Card

import { 
  Card, 
  CardHeader, 
  CardTitle, 
  CardDescription, 
  CardContent, 
  CardFooter 
} from '@bomjan133/react-crud-ui';

<Card>
  <CardHeader>
    <CardTitle>Card Title</CardTitle>
    <CardDescription>Card description text</CardDescription>
  </CardHeader>
  <CardContent>
    <p>Card content goes here</p>
  </CardContent>
  <CardFooter className="flex justify-end gap-2">
    <Button variant="outline">Cancel</Button>
    <Button>Save</Button>
  </CardFooter>
</Card>

Badge

import { Badge, badgeVariants } from '@bomjan133/react-crud-ui';

// Variants
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="info">Info</Badge>

// Status badges pattern
const StatusBadge = ({ status }) => {
  const variants = {
    active: 'success',
    pending: 'warning',
    inactive: 'secondary',
    error: 'destructive',
  };
  return <Badge variant={variants[status]}>{status}</Badge>;
};

Input & Label

import { Input } from '@bomjan133/react-crud-ui';
import { Label } from '@bomjan133/react-crud-ui';

// Basic input
<div className="space-y-2">
  <Label htmlFor="email">Email</Label>
  <Input 
    id="email" 
    type="email" 
    placeholder="Enter email"
    value={value}
    onChange={(e) => setValue(e.target.value)}
  />
</div>

// With error
<div className="space-y-2">
  <Label htmlFor="name">Name</Label>
  <Input 
    id="name" 
    className="border-destructive"
    value={value}
    onChange={(e) => setValue(e.target.value)}
  />
  <p className="text-sm text-destructive">Name is required</p>
</div>

// Disabled
<Input disabled placeholder="Disabled input" />

Select

import {
  Select,
  SelectGroup,
  SelectValue,
  SelectTrigger,
  SelectContent,
  SelectLabel,
  SelectItem,
  SelectSeparator,
} from '@bomjan133/react-crud-ui';

// Basic select
<Select value={value} onValueChange={setValue}>
  <SelectTrigger className="w-[200px]">
    <SelectValue placeholder="Select option" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="option1">Option 1</SelectItem>
    <SelectItem value="option2">Option 2</SelectItem>
    <SelectItem value="option3">Option 3</SelectItem>
  </SelectContent>
</Select>

// With groups
<Select>
  <SelectTrigger>
    <SelectValue placeholder="Select fruit" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>Fruits</SelectLabel>
      <SelectItem value="apple">Apple</SelectItem>
      <SelectItem value="banana">Banana</SelectItem>
    </SelectGroup>
    <SelectSeparator />
    <SelectGroup>
      <SelectLabel>Vegetables</SelectLabel>
      <SelectItem value="carrot">Carrot</SelectItem>
      <SelectItem value="potato">Potato</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

Checkbox & Switch

import { Checkbox } from '@bomjan133/react-crud-ui';
import { Switch } from '@bomjan133/react-crud-ui';
import { Label } from '@bomjan133/react-crud-ui';

// Checkbox
<div className="flex items-center space-x-2">
  <Checkbox 
    id="terms" 
    checked={checked}
    onCheckedChange={setChecked}
  />
  <Label htmlFor="terms">Accept terms and conditions</Label>
</div>

// Switch
<div className="flex items-center space-x-2">
  <Switch 
    id="active" 
    checked={active}
    onCheckedChange={setActive}
  />
  <Label htmlFor="active">Active</Label>
</div>

// Switch with description
<div className="flex items-center justify-between rounded-lg border p-4">
  <div className="space-y-0.5">
    <Label>Marketing emails</Label>
    <p className="text-sm text-muted-foreground">
      Receive emails about new products
    </p>
  </div>
  <Switch checked={marketing} onCheckedChange={setMarketing} />
</div>

Dialog

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@bomjan133/react-crud-ui';

<Dialog open={open} onOpenChange={setOpen}>
  <DialogTrigger asChild>
    <Button>Open Dialog</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Dialog Title</DialogTitle>
      <DialogDescription>
        This is the dialog description.
      </DialogDescription>
    </DialogHeader>
    <div className="py-4">
      {/* Dialog content */}
    </div>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button onClick={handleSubmit}>Save</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Dropdown Menu

import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuCheckboxItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSub,
  DropdownMenuSubTrigger,
  DropdownMenuSubContent,
} from '@bomjan133/react-crud-ui';

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="ghost" size="icon">
      <MoreHorizontal className="h-4 w-4" />
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent align="end">
    <DropdownMenuLabel>Actions</DropdownMenuLabel>
    <DropdownMenuSeparator />
    <DropdownMenuItem onClick={() => handleEdit(item)}>
      <Edit className="h-4 w-4 mr-2" />
      Edit
    </DropdownMenuItem>
    <DropdownMenuItem onClick={() => handleView(item)}>
      <Eye className="h-4 w-4 mr-2" />
      View
    </DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem 
      onClick={() => handleDelete(item)}
      className="text-destructive"
    >
      <Trash className="h-4 w-4 mr-2" />
      Delete
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

Tabs

import { Tabs, TabsList, TabsTrigger, TabsContent } from '@bomjan133/react-crud-ui';
import { QTabs } from '@bomjan133/react-crud-ui';

// Basic Tabs
<Tabs defaultValue="tab1">
  <TabsList>
    <TabsTrigger value="tab1">Tab 1</TabsTrigger>
    <TabsTrigger value="tab2">Tab 2</TabsTrigger>
    <TabsTrigger value="tab3">Tab 3</TabsTrigger>
  </TabsList>
  <TabsContent value="tab1">Content 1</TabsContent>
  <TabsContent value="tab2">Content 2</TabsContent>
  <TabsContent value="tab3">Content 3</TabsContent>
</Tabs>

// QTabs - Quick tabs with config
const tabs = [
  { value: 'overview', label: 'Overview', content: <Overview /> },
  { value: 'analytics', label: 'Analytics', content: <Analytics /> },
  { value: 'settings', label: 'Settings', content: <Settings /> },
];

<QTabs tabs={tabs} defaultValue="overview" />

Table

import {
  Table,
  TableHeader,
  TableBody,
  TableFooter,
  TableHead,
  TableRow,
  TableCell,
  TableCaption,
} from '@bomjan133/react-crud-ui';

<Table>
  <TableCaption>A list of recent invoices.</TableCaption>
  <TableHeader>
    <TableRow>
      <TableHead>Invoice</TableHead>
      <TableHead>Status</TableHead>
      <TableHead>Method</TableHead>
      <TableHead className="text-right">Amount</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    {invoices.map((invoice) => (
      <TableRow key={invoice.id}>
        <TableCell className="font-medium">{invoice.id}</TableCell>
        <TableCell>{invoice.status}</TableCell>
        <TableCell>{invoice.method}</TableCell>
        <TableCell className="text-right">{invoice.amount}</TableCell>
      </TableRow>
    ))}
  </TableBody>
  <TableFooter>
    <TableRow>
      <TableCell colSpan={3}>Total</TableCell>
      <TableCell className="text-right">$2,500.00</TableCell>
    </TableRow>
  </TableFooter>
</Table>

Table renders the <table> inside a scrolling wrapper. Pass containerClassName to restyle that wrapper — e.g. containerClassName="overflow-visible" to drop the scroll container so a sticky header can pin to the viewport instead of to the wrapper.


Avatar

import { Avatar, AvatarImage, AvatarFallback } from '@bomjan133/react-crud-ui';

<Avatar>
  <AvatarImage src="/avatar.jpg" alt="User" />
  <AvatarFallback>JD</AvatarFallback>
</Avatar>

// With size
<Avatar className="h-16 w-16">
  <AvatarImage src="/avatar.jpg" alt="User" />
  <AvatarFallback className="text-lg">JD</AvatarFallback>
</Avatar>

Tooltip

import { 
  Tooltip, 
  TooltipTrigger, 
  TooltipContent, 
  TooltipProvider 
} from '@bomjan133/react-crud-ui';

// Wrap your app with TooltipProvider
<TooltipProvider>
  <App />
</TooltipProvider>

// Usage
<Tooltip>
  <TooltipTrigger asChild>
    <Button variant="ghost" size="icon">
      <Info className="h-4 w-4" />
    </Button>
  </TooltipTrigger>
  <TooltipContent>
    <p>This is helpful information</p>
  </TooltipContent>
</Tooltip>

Date Picker

import { DatePicker, DateRangePicker } from '@bomjan133/react-crud-ui';

// Single date picker
const [date, setDate] = useState();
<DatePicker
  date={date}
  onDateChange={setDate}
  placeholder="Pick a date"
/>

// Date range picker
const [dateRange, setDateRange] = useState();
<DateRangePicker
  dateRange={dateRange}
  onDateRangeChange={setDateRange}
  placeholder="Pick a date range"
/>

Rich Text Editor

import { RichTextEditor } from '@bomjan133/react-crud-ui';

const [content, setContent] = useState('');

<RichTextEditor
  value={content}
  onChange={setContent}
  placeholder="Start writing..."
  height="300px"
/>

Separator

import { Separator } from '@bomjan133/react-crud-ui';

// Horizontal
<div>
  <h2>Section 1</h2>
  <Separator className="my-4" />
  <h2>Section 2</h2>
</div>

// Vertical
<div className="flex h-5 items-center space-x-4">
  <span>Item 1</span>
  <Separator orientation="vertical" />
  <span>Item 2</span>
  <Separator orientation="vertical" />
  <span>Item 3</span>
</div>

Skeleton

import { Skeleton } from '@bomjan133/react-crud-ui';

// Basic skeleton
<Skeleton className="h-4 w-[250px]" />

// Card skeleton
<div className="flex items-center space-x-4">
  <Skeleton className="h-12 w-12 rounded-full" />
  <div className="space-y-2">
    <Skeleton className="h-4 w-[250px]" />
    <Skeleton className="h-4 w-[200px]" />
  </div>
</div>

// Table skeleton
<div className="space-y-2">
  {[1, 2, 3, 4, 5].map((i) => (
    <Skeleton key={i} className="h-10 w-full" />
  ))}
</div>

Alert

import { 
  Alert, 
  AlertTitle, 
  AlertDescription, 
  AlertWithIcon, 
  alertVariants 
} from '@bomjan133/react-crud-ui';

// Basic alert
<Alert>
  <AlertTitle>Heads up!</AlertTitle>
  <AlertDescription>
    You can add components to your app using the cli.
  </AlertDescription>
</Alert>

// Destructive alert
<Alert variant="destructive">
  <AlertTitle>Error</AlertTitle>
  <AlertDescription>
    Your session has expired. Please log in again.
  </AlertDescription>
</Alert>

// With icon
<AlertWithIcon type="success" title="Success" message="Changes saved successfully" />
<AlertWithIcon type="error" title="Error" message="Something went wrong" />
<AlertWithIcon type="warning" title="Warning" message="This action cannot be undone" />
<AlertWithIcon type="info" title="Info" message="New features available" />

Complete reference for CRUD components in @bomjan133/react-crud-ui.

Table of Contents

  1. DataTable
  2. CrudList
  3. CrudForm
  4. CrudDetail
  5. Pagination
  6. Loading Components
  7. Empty States
  8. Modal Components

DataTable

A data table with sorting, pagination, search, row selection, clickable "link" columns, and a per-row dropdown actions menu.

Props

| Prop | Type | Default | Description | |------|------|---------|--------------| | data | array | [] | Rows to display (current page only — this component does not paginate client-side). | | columns | array | [] | Column definitions. See Column Definition below. | | loading | boolean | false | Shows a full-width Loading row instead of the data rows. | | error | any | null | Accepted but currently unused — nothing in the component reads or renders this value. | | pagination | boolean | null | Only meaningful when set to false, which hides the pagination footer entirely. Any other value (including the default null) shows it. Pagination is otherwise driven by the flat props below — it is not an object with { page, totalPages, total }. | | currentPage | number | 1 | Current page (1-indexed). Used to compute serial numbers and passed through to the internal Pagination component. | | pageSize | number | 15 | Items per page. Used to compute serial numbers and passed to Pagination. | | totalItems | number | 0 | Total item count, passed to Pagination. | | totalPages | number | 1 | Total page count, passed to Pagination. | | onPageChange | function | - | Passed to Pagination; called with the new page. | | onPageSizeChange | function | - | Passed to Pagination; called when the page size changes. | | sortable | boolean | true | Global switch for sorting. A column must also set sortable: true to become sortable. | | sortColumn | string | null | The field currently sorted on (compared against each column's sortKey/accessor/key). | | sortDirection | 'asc' \| 'desc' \| null | null | Current sort direction. | | onSort | function | - | Called as onSort(field, direction) when a sortable header is clicked. Clicking cycles asc → desc → none; on the third click it's called as onSort(null, null). | | openInNewTab | boolean | false | When a column renders as a link (via onViewLink), opens it in a new tab (target="_blank") instead of the same tab. | | searchable | boolean | true | Shows/hides the search input in the toolbar. | | searchValue | string | '' | Initial value only — seeds the component's internal search state on mount. The input is managed internally after that; changing this prop later does not reset the input. | | onSearchChange | function | - | Called with the search string, debounced 300ms after the user stops typing (fixed delay, not configurable). | | searchPlaceholder | string | 'Search...' | Placeholder text for the search input. | | filters | node | - | Extra filter control(s) rendered next to the search box. Accepts a single element or an array/fragment of elements; each is wrapped in a w-[200px] container. | | toolbarActions | node | - | Content rendered on the right side of the toolbar (e.g. an "Add" button). | | selectable | boolean | false | Shows a checkbox column for row selection, plus a tri-state select-all checkbox in the header. | | selectedRows | array | [] | Array of currently-selected row keys (as returned by getRowKey). | | onSelectionChange | function | - | Called with the new array of selected row keys, on both row and select-all/clear actions. |

Select-all header checkbox

When selectable is set, the header cell renders a tri-state checkbox that reflects the rows on the current page:

| Rows selected on this page | Header state | Clicking it | |---|---|---| | None | unchecked | selects every row on the page | | Some, but not all | indeterminate (a dash, not a tick) | selects every row on the page | | All | checked | deselects every row on the page |

Selections made on other pages are preserved — select-all merges the current page's keys into selectedRows, and deselect-all removes only that page's keys. To wipe the selection entirely, call onSelectionChange([]) yourself (that is what the toolbar's "Clear" button does). | actions | array | [] | Row-level dropdown menu actions. There is no onEdit/onDelete/onView/showActions prop on DataTable itself — a single actions array drives the whole menu. See Actions Column below. | | onRowClick | function | - | Called with the row object when a row is clicked (ignored if the click originates from the checkbox or actions cells, which stop propagation). | | striped | boolean | false | Applies a light background to odd rows. | | hover | boolean | true | Gates two visual behaviors: shows a pointer cursor on rows when onRowClick is set, and (when onRowClick is not set) highlights selected rows with a tinted background. It does not add a generic hover background by itself. | | compact | boolean | false | Reduces cell vertical padding. | | className | string | - | Class for the outer wrapping <div>. | | emptyTitle | string | 'No data found' | Empty-state title. | | emptyDescription | string | 'There are no items to display.' | Empty-state description text. | | emptyAction | string | - | Label for an optional empty-state action button. | | onEmptyAction | function | - | Handler for the empty-state action button. | | getRowKey | function | (row) => row.id \|\| row._id | Derives a unique key per row; used for React keys and selection tracking. | | showSerialNumber | boolean | true | Shows a leading row-number column. | | serialNumberHeader | string | 'SN' | Header label for the serial number column. | | onViewClick | function | - | If set, "clickable" columns (see clickableColumns) render their value as a button; clicking it calls onViewClick(row). | | onViewLink | function | - | If set, "clickable" columns render their value as an <a>; called as onViewLink(row) to compute the href. Takes precedence over onViewClick. | | onBeforeNavigate | function | - | Only used when onViewLink is set and openInNewTab is false. If provided, clicking the link is intercepted (preventDefault/stopPropagation) and onBeforeNavigate(href) is called instead of navigating. | | clickableColumns | array | [] | List of column accessor/key strings that should render as links/buttons. Pass ['*'] to make every column clickable. If left empty, only columns whose accessor/key is "name" or "title" are clickable by default. | | enableTextClamping | boolean | true | Truncates long string cell values (30+ characters) with an ellipsis and shows the full text in a tooltip on hover. | | defaultMaxLines | number | 1 | Default number of lines before clamping; overridable per-column via column.maxLines. | | tableClassName | string | - | Class passed to the root <Table>. | | tableHeaderClassName | string | - | Class passed to <TableHeader>. | | tableBodyClassName | string | - | Class passed to <TableBody>. | | tableRowClassName | string | - | Class merged onto every data/status <TableRow>. | | tableHeadClassName | string | - | Class merged onto every <TableHead> cell (selection, SN, data, and actions headers). | | tableCellClassName | string | - | Class merged onto every <TableCell> (selection, SN, data, actions, and loading/empty cells). | | headerRowClassName | string | - | Class merged onto the header <TableRow>. | | statusRowClassName | string | - | Class merged onto the loading/empty-state <TableRow>. | | paginationClassName | string | - | Class passed to the Pagination component. | | stickyPagination | boolean | false | Pins the pagination bar to the bottom of the viewport while the table scrolls, so page controls stay reachable on long pages (e.g. the default 15 rows). | | stickyPaginationClassName | string | - | Class on the sticky pagination wrapper — use it to offset the bar (bottom-16) or restyle its surface. | | stickyHeader | boolean | false | Pins the column headers to the top of the viewport while the table scrolls. Enabling this removes the table's horizontal scrolling (see note below). | | stickyHeaderOffset | number \| string | auto | Distance from the top of the viewport where the header parks. A number is treated as pixels. Left unset it reads --rcu-header-offset, which DashboardLayout sets to its own header height — so the table header parks below the app header instead of behind it. Falls back to 0. | | stickyHeaderClassName | string | - | Class merged onto every pinned <th> — use it to change the pinned background or z-index. |

Not implemented: onEdit, onDelete, onView, showActions, and emptyMessage do not exist on DataTable — they were fabricated in an earlier version of this doc. Use actions (below) and emptyTitle/emptyDescription instead.

stickyHeader and horizontal scrolling are mutually exclusive. A sticky element pins to its nearest scrolling ancestor, so while the table sits inside a horizontally scrollable wrapper the header can only pin to that wrapper — never to the window. Turning on stickyHeader therefore drops the wrapper's overflow-auto, and a table wider than its container will be clipped rather than scrollable. Leave stickyHeader off for wide tables, or give the columns narrower width/maxWidth values first.

Column Definition

const columns = [
  {
    // Value extraction — accessor wins over key
    accessor: 'name',        // string, OR a function: (row) => value
    key: 'name',              // fallback for accessor; also used as React key/sort field if accessor is absent

    // Header text — header wins over label
    header: 'Name',
    label: 'Name',             // fallback if header is not set

    sortable: true,             // enables sorting for this column (requires top-level sortable !== false)
    sortKey: 'name',            // optional override of the field name used for sort comparisons/onSort

    render: (value, row, index) => (
      <span className="font-medium">{value}</span>
    ),                          // custom cell renderer, highest precedence
    format: (value, row) => value,  // used only if render is not provided

    width: 200,                 // inline style: width
    minWidth: 120,               // inline style: minWidth
    maxWidth: 300,               // inline style: maxWidth (cells default to '300px' if unset)

    headerClassName: '',         // extra className on the <TableHead>
    cellClassName: '',           // extra className on the <TableCell>

    disableClamping: false,      // opt this column out of enableTextClamping
    maxLines: 1,                 // per-column override of defaultMaxLines
  },
];

A column-level className field does not exist — the real per-column style hooks are headerClassName and cellClassName.

Actions Column

Passing a non-empty actions array is what causes the Actions column (header cell + per-row dropdown trigger) to render — there is no separate showActions toggle.

const actions = [
  {
    label: 'Edit',
    icon: PencilIcon,               // optional component, rendered at 16px with right margin
    onClick: (row) => handleEdit(row),
    disabled: false,                 // optional
  },
  {
    label: 'Delete',
    icon: TrashIcon,
    variant: 'destructive',          // only 'destructive' has special styling (red text)
    onClick: (row) => handleDelete(row),
    hidden: (row) => row.locked,      // boolean, or a function(row) => boolean
  },
];
  • Items render in a DropdownMenu opened by an icon-only ghost button, in the same order as the actions array.
  • Per row, an action is filtered out of the menu if hidden is true, or if hidden is a function returning true for that row.
  • disabled maps directly to the underlying menu item's disabled state.
  • Clicking a row's actions cell does not trigger onRowClick (click propagation is stopped).

Usage

import { DataTable } from '@bomjan133/react-crud-ui';
import { PencilIcon, TrashIcon } from 'lucide-react';

<DataTable
  data={users}
  columns={[
    { key: 'name', label: 'Name', sortable: true },
    { key: 'email', label: 'Email' },
    {
      key: 'status',
      label: 'Status',
      render: (value) => <Badge>{value}</Badge>,
    },
  ]}
  loading={isLoading}
  searchValue={search}
  onSearchChange={setSearch}
  currentPage={page}
  pageSize={15}
  totalItems={total}
  totalPages={Math.ceil(total / 15)}
  onPageChange={setPage}
  sortColumn={sortColumn}
  sortDirection={sortDirection}
  onSort={(field, direction) => {
    setSortColumn(field);
    setSortDirection(direction);
  }}
  actions={[
    { label: 'Edit', icon: PencilIcon, onClick: (row) => openEdit(row) },
    {
      label: 'Delete',
      icon: TrashIcon,
      variant: 'destructive',
      onClick: (row) => openDelete(row),
    },
  ]}
  emptyTitle="No users found"
  emptyDescription="Try adjusting your search or add a new user."
/>

// Selectable rows with a clickable "name" column linking to a detail page
<DataTable
  data={orders}
  columns={[{ key: 'name', label: 'Order' }, { key: 'amount', label: 'Amount' }]}
  selectable
  selectedRows={selectedIds}
  onSelectionChange={setSelectedIds}
  onViewLink={(row) => `/orders/${row.id}`}
  clickableColumns={['name']}
  getRowKey={(row) => row.id}
/>

CrudList

A complete list view component that combines DataTable with a header, toolbar (search/create/refresh/export/bulk actions), pagination, row actions (view/edit/delete + custom), and a delete-confirmation modal. It can source its data either from a store object or from direct props — each store capability (items, loading, error, pagination, fetchList, delete, setPage, setPageSize, setFilters, sorting) is read independently via optional chaining and falls back to the matching direct prop if the store doesn't provide it.

Store integration (preferred)

| Prop | Type | Default | Description | |------|------|---------|--------------| | store | object | - | A store instance (e.g. a Zustand store). Recognized shape: store.items (array), store.loading (bool), store.error, store.pagination ({ page, pageSize, total, totalPages }), store.fetchList(), store.delete(id), store.setPage(page), store.setPageSize(size), store.setFilters({ search }), store.sortColumn, store.sortDirection, store.setSort(column, direction). None of these are required — missing pieces fall back to the direct props below. |

Note if you're using this package's own createCrudStore: its pagination fields are flat (page/pageSize/totalItems/totalPages), not a nested pagination object. Since CrudList specifically reads store.pagination, a plain createCrudStore instance won't provide it and pagination will appear stuck — bridge it yourself, e.g. store={{ ...store, pagination: { page: store.page, pageSize: store.pageSize, total: store.totalItems, totalPages: store.totalPages } }}. See Store Factory → Compatibility for the full compatibility matrix (this also applies to CrudDetail's currentItem vs. item, and CrudForm's store mode).

Direct data props (used if store not provided)

| Prop | Type | Default | Description | |------|------|---------|--------------| | data | array | - | Row data. Used when store?.items is nullish. | | loading | boolean | false | Loading state, used when store?.loading is nullish. | | error | any | null | Error state, used when store?.error is nullish. | | skipInitialFetch | boolean | false | When a store is provided, skips the automatic call to store.fetchList() on mount. |

Columns configuration

| Prop | Type | Default | Description | |------|------|---------|--------------| | columns | array | [] | Column definitions, forwarded to the internal DataTable — see DataTable's Column Definition for the full field list (accessor/key, header/label, sortable, sortKey, render, format, width fields, headerClassName/cellClassName, clamping options). |

Pagination (direct-prop mode)

| Prop | Type | Default | Description | |------|------|---------|--------------| | currentPage | number | 1 | Current page, used when no store.pagination value is available. | | pageSize | number | 15 | Page size fallback. | | totalItems | number | 0 | Total item count fallback. | | totalPages | number | 1 | Total page count fallback. | | onPageChange | function(page) | - | Called when the store has no setPage method. | | onPageSizeChange | function(size) | - | Called when the store has no setPageSize method. |

There is no showPagination prop. CrudList never passes a pagination prop through to DataTable, and DataTable renders its pagination footer whenever there is data — so pagination is always shown once you have data.

Sorting

| Prop | Type | Default | Description | |------|------|---------|--------------| | sortColumn | string | - | Current sort column, used when the store doesn't expose sortColumn. | | sortDirection | 'asc' \| 'desc' \| null | - | Current sort direction fallback. | | onSort | function(column, direction) | - | Called on sort-header click when no store is present. When a store is present, sort clicks instead call store.setSort(column, direction) followed by store.fetchList(), regardless of whether onSort is also passed. |

Search

| Prop | Type | Default | Description | |------|------|---------|--------------| | searchValue | string | "" | Controlled search text, used only when the store has no setFilters method. | | onSearchChange | function(value) | - | Called on search input change when the store has no setFilters method. | | searchPlaceholder | string | "Search..." | Placeholder text for the search input. | | searchable | boolean | true | Whether the search input (and debounced store search) is enabled. This is the real prop for what an earlier version of this doc called showSearch, which does not exist. | | openInNewTab | boolean | false | When true, clickable-column/view links open via window.open(..., "_blank") instead of SPA navigation, and scroll-position save/restore is skipped. |

When a store with setFilters is present, typing in the search box updates internal state, and after a 300ms debounce calls store.setFilters({ search }), resets to page 1, then calls store.fetchList().

Selection

| Prop | Type | Default | Description | |------|------|---------|--------------| | selectable | boolean | false | Enables row checkboxes and the tri-state "select all" header checkbox (see Select-all header checkbox). | | selectedRows | array | [] | Array of currently-selected row keys (as returned by getRowKey). Controlled — the component does not manage this internally. | | onSelectionChange | function(newSelectedRowKeys: array) | - | Called with the full updated array whenever a row or "select all" checkbox changes. | | showBulkDelete | boolean | false | Shows a trash icon inside the "N selected" chip. Clicking it opens a confirmation modal. | | onBulkDelete | function(selectedRowKeys: array) | - | Runs on confirm. If omitted, CrudList falls back to calling the store's delete once per selected key. | | bulkDeleteLabel | string | "Delete selected" | Tooltip/aria-label on the trash icon. |

Selection is uncontrolled by default — selectable on its own is enough, and CrudList holds the array internally. Pass selectedRows to take control (needed when something outside the table, like an export button, has to read the selection); onSelectionChange still fires either way.

CRUD Actions (direct-prop mode, used if paths not provided)

| Prop | Type | Default | Description | |------|------|---------|--------------| | onCreate | function() | - | Called by the Create button when createPath is not set. | | onView | function(row) | - | Called by the row "View" action when detailPath is not set. | | onEdit | function(row) | - | Called by the row "Edit" action when editPath is not set. | | onDelete | function(row): Promise | - | Called on delete confirmation when the store has no delete method. Awaited; success shows a toast, thrown errors show an error toast. | | onRefresh | function() | - | Called by the Refresh button when the store has no fetchList method. | | onExport | function() \| React element | - | If a valid React element, it is rendered as-is in the toolbar. If a function, a default "Export" button is rendered that calls it on click. |

Table & button styling overrides

Purely presentational, forwarded straight through to DataTable/toolbar buttons:

| Prop | Type | Description | |------|------|-------------| | tableClassName | string | Class on the <Table> element. | | tableHeaderClassName | string | Class on <TableHeader>. | | tableBodyClassName | string | Class on <TableBody>. | | tableRowClassName | string | Class on each <TableRow> (data + status rows). | | tableHeadClassName | string | Class on each <TableHead> cell. | | tableCellClassName | string | Class on each <TableCell>. | | headerRowClassName | string | Class on the header <TableRow>. | | statusRowClassName | string | Class on the loading/empty status row. | | paginationClassName | string | Class passed to the Pagination component. | | stickyPagination | boolean | Pins the pagination bar to the bottom of the viewport while the list scrolls (default false). | | stickyPaginationClassName | string | Class on the sticky pagination wrapper — use it to offset the bar (bottom-16) or restyle its surface. | | stickyHeader | boolean | Pins the column headers to the top of the viewport while the list scrolls (default false). Removes the table's horizontal scrolling. | | stickyHeaderOffset | number \| string | Where the pinned header parks, measured from the top of the viewport; a number means pixels. Defaults to --rcu-header-offset, which DashboardLayout sets to its own header height, else 0. | | stickyHeaderClassName | string | Class merged onto every pinned <th>. | | createButtonClassName | string | Class on the Create button. | | refreshButtonClassName | string | Class on the Refresh button. | | exportButtonClassName | string | Class on the default Export button. | | bulkActionButtonClassName | string | Class applied to every bulk-action button. |

Navigation paths

| Prop | Type | Default | Description | |------|------|---------|--------------| | createPath | string | - | If set, the Create button navigates here (via react-router's navigate) instead of calling onCreate. Used as-is — no suffix appended. | | detailPath | string | - | If set, the row "View" action and clickable-name columns navigate to `${detailPath}/${getRowKey(row)}` instead of calling onView. | | editPath | string | - | If set, the row "Edit" action navigates to `${editPath}/${getRowKey(row)}/edit` instead of calling onEdit. |

Correction: there is no viewPath prop — the real prop is detailPath. An earlier version of this doc used viewPath, which has no effect.

Breadcrumb helper

| Prop | Type | Default | Description | |------|------|---------|--------------| | getBreadcrumbName | function(row) | - | Returns a display name passed as location.state.breadcrumbName when navigating to the detail page. | | customBreadcrumbPaths | object | - | Static breadcrumb path map passed as location.state.customBreadcrumbPaths on create/view/edit navigation, unless getCustomBreadcrumbPath is given. | | getCustomBreadcrumbPath | function(row) | - | Per-row/per-action function that computes breadcrumb paths, overriding customBreadcrumbPaths when provided. | | getParentPath | function(row) | - | Returns a parent path string; used to build { [firstPathSegment]: parentPath } (passed as location.state.parentPath) when neither customBreadcrumbPaths nor getCustomBreadcrumbPath produced a result. | | preserveLocationState | boolean | false | If true, spreads the current location.state into the state object passed to create/view/edit navigations. |

Custom actions

| Prop | Type | Default | Description | |------|------|---------|--------------| | additionalActions | array | [] | Extra per-row dropdown actions, inserted between the built-in Edit and Delete actions. Each item: { label, icon?: Component, onClick(row), variant?: 'destructive', disabled?: boolean, hidden?: boolean \| (row) => boolean }. | | bulkActions | array | [] | Toolbar buttons shown only when selectable is true and at least one row is selected. Each item: { label, icon?: Component, variant?: string (default "outline"), onClick(selectedRowKeys), disabled?: boolean, className?: string }. |

Labels

| Prop | Type | Default | Description | |------|------|---------|--------------| | title | string | - | Card title text. Also used verbatim in the delete-success toast: `${title} Deleted successfully` (note: uses title, not itemName). | | description | string | - | Card description text. | | createLabel | string | "Create" | Create button label. | | refreshLabel | string | "Refresh" | Refresh button label. | | exportLabel | string | "Export" | Default Export button label. | | viewLabel | string | "View" | Row "View" action label. | | editLabel | string | "Edit" | Row "Edit" action label. | | deleteLabel | string | "Delete" | Row "Delete" action label. | | itemName | string | "item" | Used in the default delete-confirmation title/description and the empty-state text (`No ${itemName}s found`, `Create your first ${itemName} to get started.`). |

Row key

| Prop | Type | Default | Description | |------|------|---------|--------------| | getRowKey | function(row) | (row) => row.uuid \|\| row.id \|\| row._id | Derives a unique key/id for each row; used for selection, navigation URLs, delete calls, and React keys. |

Appearance

| Prop | Type | Default | Description | |------|------|---------|--------------| | showHeader | boolean | true | Whether to wrap content in a Card with a header (title/description/toolbar). If false (or no title/description), content renders in a plain <div>. | | showToolbar | boolean | true | Whether to render the toolbar (header actions, bulk actions, export, refresh, create) — independent of showHeader. | | compact | boolean | false | Passed to DataTable to tighten row padding. | | className | string | - | Class on the outer Card/div wrapper. | | filters | node | - | Custom filter control(s) rendered in the DataTable toolbar next to the search box. | | headerActions | node | - | Arbitrary content rendered at the start of the toolbar, before bulk actions/export/refresh/create. This is the real "custom header actions" slot — children is not read anywhere in CrudList and has no effect. | | showEditButton | boolean | true | Hides the built-in row "Edit" action when false, even if editPath/onEdit is set. | | showDeleteButton | boolean | true | Hides the built-in row "Delete" action when false, even if delete is otherwise available. | | clickableColumns | array | [] | Accessor keys that should render as clickable links/buttons to the detail view. Pass ["*"] for all columns. Forwarded to DataTable. |

Correction: icon (a title icon) and children (custom header actions) do not exist on CrudList — both were fabricated in an earlier version of this doc. Use headerActions for custom toolbar content.

Delete confirmation

| Prop | Type | Default | Description | |------|------|---------|--------------| | deleteConfirmTitle | string | - | Overrides the delete modal's title (default: `Delete ${itemName}`). | | deleteConfirmDescription | string | - | Overrides the modal's description (default asks to confirm deleting the resolved item name). | | getDeleteItemName | function(item) | - | Returns the display name for the item being deleted (HTML-stripped and truncated to 80 chars internally); falls back to item.name \|\| item.title \|\| itemName. |

There is no onDeleteSuccess callback — delete success always shows a hardcoded toast using title (see Labels above).

Usage

Basic (store-based):

import { CrudList } from '@bomjan133/react-crud-ui';
import { useUserStore } from './stores/useUserStore';

const columns = [
  { accessor: 'name', header: 'Name', sortable: true },
  { accessor: 'email', header: 'Email' },
  {
    accessor: 'createdAt',
    header: 'Created',
    format: (value) => new Date(value).toLocaleDateString(),
  },
];

function UsersPage() {
  const store = useUserStore();

  return (
    <CrudList
      store={store}
      title="Users"
      description="Manage your organization's users"
      columns={columns}
      createPath="/users/create"
      detailPath="/users"
      editPath="/users"
      itemName="user"
      getRowKey={(row) => row.id}
    />
  );
}

Selection + bulk actions (direct data props):

import { useState } from 'react';
import { CrudList } from '@bomjan133/react-crud-ui';
import { Archive, Trash2 } from 'lucide-react';

const columns = [
  { accessor: 'name', header: 'Name', sortable: true },
  { accessor: 'status', header: 'Status' },
];

function InvoicesPage({ invoices, isLoading }) {
  const [selectedRows, setSelectedRows] = useState([]);

  const bulkActions = [
    { label: 'Archive', icon: Archive, onClick: (rowKeys) => archiveInvoices(rowKeys) },
    { label: 'Delete', icon: Trash2, variant: 'destructive', onClick: (rowKeys) => deleteInvoices(rowKeys) },
  ];

  return (
    <CrudList
      data={invoices}
      loading={isLoading}
      columns={columns}
      title="Invoices"
      itemName="invoice"
      selectable
      selectedRows={selectedRows}
      onSelectionChange={setSelectedRows}
      bulkActions={bulkActions}
      onCreate={() => openCreateModal()}
      onView={(row) => openDetailModal(row)}
      onEdit={(row) => openEditModal(row)}
      onDelete={async (row) => deleteInvoice(row.id)}
      getRowKey={(row) => row.id}
    />
  );
}

CrudForm

A form component for create/edit operations. It is dual-mode — which mode renders is decided by a single runtime check: useStore && children (both truthy).

  • Store mode (useStore and children both supplied): renders children verbatim inside a <form>, does its own lightweight "required" validation by inspecting each child's name/required/label props, and calls store.create/store.update. fields, onSubmit, initialData, defaultValues, validationSchema, and columns are all ignored in this mode.
  • Fields mode (anything else — including useStore without children, or children without useStore): a self-contained react-hook-form + optional Zod form driven by the fields array and onSubmit. useStore, children, onBeforeSubmit, onAfterCreate, and getBreadcrumbName are all ignored in this mode — children isn't even rendered.

CrudForm unconditionally calls useNavigate()/useParams() (react-router) and useBreadcrumb(), so it must be rendered under a router and a BreadcrumbProvider regardless of which mode you use.

⚠️ Store-shape warning: CrudForm's store mode expects a store exposing form.values, form.isSubmitting, create(data), update(id, data), resetForm(), setFormErrors(errorsObj), and one of fetchAndPopulateForm(id) / fetchById(id) / fetchOne(id). The createCrudStore/createStore factory shipped in this same package does not produce this shape — it exposes item, fetchOne, create, update, loading, submitting, error, with no form, resetForm, setFormErrors, or fetchAndPopulateForm. If you're using createCrudStore, use fields mode instead (fields + onSubmit), or hand-write a store matching store mode's expected shape.

Props

| Prop | Type | Default | Description | |------|------|---------|--------------| | useStore | function | - | Zustand store hook. Combined with children to trigger store mode. See store-shape warning above. | | children | node | - | Store mode only. Rendered as-is inside <form> — CrudForm does not clone children or inject value/onChange. Each field is a fully generic controlled component; you must wire value/onChange to the store yourself. CrudForm only recursively reads each descendant's name, required, and label props to run its own "is empty" required-field check — unrelated to validationSchema/Zod. If passed without useStore (or vice versa), it is silently dropped and fields mode renders instead. | | fields | array | [] | Fields mode only. Array of field descriptors: { name, type, label, placeholder, description, required, disabled, options, className, hidden, colSpan, render, ...rest }. type is one of text \| email \| password \| number \| url \| tel \| textarea \| select \| search-select \| switch \| section \| custom (unknown/omitted types fall back to a text QInput). hidden may be a boolean or (allValues) => boolean. colSpan is 1 \| 2 \| 3 \| 4 \| 'full'. type: 'section' renders a divider using its own title/description/icon. type: 'custom' calls field.render({ form, field, error, value }). | | defaultValues | object | {} | Fields mode only. Initial react-hook-form values, used when initialData is not provided. | | validationSchema | Zod schema | - | Fields mode only. Passed to zodResolver for react-hook-form validation. No effect in store mode. | | initialData | object | null | Fields mode only. When present, seeds and re-syncs the form (reset()) — the fields-mode equivalent of "edit" data. Store mode instead fetches edit data itself via fetchById/fetchAndPopulateForm. | | onSubmit | function(data) | - | Fields mode only. Called with the react-hook-form-validated data. Thrown errors are caught and shown via a toast; there is no built-in success toast in fields mode (unlike store mode) — handle success yourself. | | onCancel | function | - | Store mode: called by the Cancel button (after resetForm()); if omitted, Cancel falls back to the back-navigation handler. Fields mode: the Cancel button is only rendered if onCancel is truthy, and clicking it calls onCancel directly with no fallback. | | onBack | function | - | Store mode: the back-arrow button (when showBack is true) always calls a handleBack() that uses onBack if given, else navigate(listPath), else navigate(-1). Fields mode: the back-arrow button is only rendered if showBack && onBack are both truthy, and calls onBack directly (no listPath/navigate(-1) fallback). | | onBeforeSubmit | function(values) => values | - | Store mode only. Called synchronously (not awaited) with the current store.form.values right before submit. Its return value directly replaces the values passed to create/update — there is no "return false to cancel" behavior; it must always return an object. | | onAfterCreate | function(result, navigate) | - | Store mode only, and only fires after create (never update), and only if create() resolved with a truthy result. If omitted (or the result is falsy), falls back to the back-navigation handler. No equivalent hook exists for the update/edit path. | | title | string | - | Store mode: form title, auto-prefixed as `Edit ${title}` or `Create ${title}` depending on edit-mode detection (see below), and used in the auto success toast ("{title} created/updated successfully", default "Item" if omitted). Fields mode: rendered as-is (no Edit/Create prefix), and only shown at all if showHeader and (title or description or showBack) are truthy. | | description | string | - | Subtitle shown under the title, both modes. | | submitLabel | string | "Save" | Submit button text, both modes. Shows a spinner + "Saving..." while submitting. | | cancelLabel | string | "Cancel" | Cancel button text, both modes (subject to the onCancel visibility rule above). | | columns | 1 \| 2 \| 3 \| 4 | 1 | Fields mode only. Grid column count for laying out fields. No effect in store mode. | | showHeader | boolean | true | Fields mode only. Gates whether the Card+header wrapper is used (vs. a plain <div>). No effect in store mode (its header is unconditional). | | showFooter | boolean | true | Shows/hides the Cancel/Submit button row, both modes. | | showBack | boolean | true | Shows the back-arrow icon button. See the asymmetric per-mode behavior under onBack. | | showCard | boolean | true | Only honored in store mode (wraps output in Card vs. a plain <div>). In fields mode, Card-vs-div is decided solely by showHeader && (title \|\| description \|\| showBack) — this prop is read but never checked there. | | listPath | string | - | Fallback destination for handleBack() when no onBack is given, in both modes. | | icon | component | - | Only rendered in store mode when showCard is false. Not used in the Card-wrapped store header, and not used anywhere in fields mode. | | className | string | - | Applied to the outer Card/div wrapper, both modes. | | getBreadcrumbName | function(values) | - | Store mode only, active only while editing (uuid route param present). Called with store.form.values once they look populated; its return value is registered via useBreadcrumb().setBreadcrumbName(uuid, name) and cleared on unmount. Requires an ancestor BreadcrumbProvider. | | loading | boolean | false | OR'd with the store's own loading (store mode). Disables fields and combines with submitting to disable Submit. | | submitting | boolean | false | OR'd with the store's own form.isSubmitting (store mode). Disables Cancel/Submit and swaps Submit's label/icon for a spinner + "Saving...". |

Not implemented / dead props: there is no onSuccess callback — store mode's success message is a hardcoded toast ("{title} created/updated successfully"); the closest customization hook is onAfterCreate (create only). backLabel is destructured but never rendered anywhere — it has no effect.

Edit-mode detection: create-vs-edit is determined solely by Boolean(useParams().uuid) — CrudForm must be rendered on a route with a literal :uuid param (not :id, and not an id prop). This flag drives the title prefix, whether fetchById/fetchAndPopulateForm/fetchOne runs, and whether submit calls store.update(uuid, values) vs store.create(values). Fields mode ignores this entirely — there, "edit" is just whatever initialData you pass in.

Usage

Store mode (requires a hand-written store matching the shape described in the warning above), rendered on routes like /users/new and /users/:uuid/edit:

import { CrudForm, QInput } from '@bomjan133/react-crud-ui';

function UserForm() {
  return (
    <CrudForm
      title="User"
      useStore={useUserFormStore}
      listPath="/users"
      getBreadcrumbName={(values) => values.name}
      onBeforeSubmit={(values) => ({ ...values, email: values.email.trim().toLowerCase() })}
      onAfterCreate={(result, navigate) => navigate(`/users/${result.uuid}/edit`)}
    >
      <QInput
        name="name"
        label="Name"
        required
        value={useUserFormStore((s) => s.form.values.name) ?? ''}
        onChange={(val) => useUserFormStore.getState().setFormValue('name', val)}
      />
      <QInput
        name="email"
        label="Email"
        required
        value={useUserFormStore((s) => s.form.values.email) ?? ''}
        onChange={(val) => useUserFormStore.getState().setFormValue('email', val)}
      />
    </CrudForm>
  );
}

Fields mode (self-contained react-hook-form, no store, no router param required — pass initialData for edit, omit it for create):

import { CrudForm } from '@bomjan133/react-crud-ui';

<CrudForm
  title="User"
  description="Basic account details"
  fields={[
    { name: 'email', type: 'email', label: 'Email', required: true },
    {
      name: 'role',
      type: 'select',
      label: 'Role',
      options: [
        { value: 'admin', label: 'Admin' },
        { value: 'member', label: 'Member' },
      ],
    },
  ]}
  initialData={editingUser} // omit for create mode
  columns={2}
  onSubmit={async (data) => {
    await api.saveUser(data);
    toast.success('Saved');
  }}
  onCancel={() => navigate('/users')}
  onBack={() => navigate('/users')}
/>

CrudDetail

A detail-view component for a single record. It supports two distinct rendering modes, and can source its data either from a Zustand-style store hook (useStore) or directly via a data prop.

Mode selection is automatic and based on children:

  • Store + render-prop mode — used only when both useStore is passed and children is a function. Renders whatever children(data) returns inside a plain header + content wrapper (no Card).
  • Fields-based mode — used in every other case (no useStore, or useStore without a function children, or children that isn't a function). Renders a Card with a field grid built from the fields array. children is completely ignored in this mode.

useStore can also be used purely for fetching/deleting without triggering render-prop mode, as long as children isn't a function — data then comes from the store and renders via fields.

⚠️ children is a render-prop function, not a plain node, and only takes effect when `us