A feature-rich, server-side data table implementation built with React, TanStack Table, Laravel, and Inertia.js. This data table provides pagination, sorting, search, bulk actions, and column visibility controls out of the box.
Main datatable view with pagination, search, sorting, column visibility, bulk actions
- React 18 - Modern UI library with hooks and concurrent features
- TypeScript - Type-safe JavaScript with excellent developer experience
- Inertia.js - Modern monolith approach connecting Laravel and React seamlessly
- TanStack Table - Powerful headless table library for complex data interactions
- Tailwind CSS - Utility-first CSS framework for rapid UI development
- Radix UI - Unstyled, accessible UI primitives for custom design systems
- Lucide Icons - Beautiful & consistent icon library
- Vite - Fast build tool and development server
- Laravel 11 - Elegant PHP framework with rich ecosystem
- PHP 8.3+ - Modern PHP with performance improvements and type safety
- MySQL/PostgreSQL - Robust database with full-text search capabilities
- Laravel Resources - API resource transformation for consistent data formatting
- Laravel Pagination - Built-in pagination with query string persistence
Make sure you have the following installed on your system:
- PHP 8.2+ with extensions:
mbstring
,xml
,ctype
,json
,bcmath
,fileinfo
,tokenizer
- Composer - PHP dependency manager
- Node.js 18+ and npm (or yarn/pnpm)
- MySQL 8.0+ or PostgreSQL 13+
- Git
-
Clone the repository
git clone https://github.com/your-username/data-table.git cd data-table
-
Install PHP dependencies
composer install
-
Install Node.js dependencies
npm install # or yarn install # or pnpm install
-
Environment setup
# Copy environment file cp .env.example .env # Generate application key php artisan key:generate
-
Configure your
.env
fileAPP_NAME="Data Table" APP_URL=http://localhost:8000 DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=data_table DB_USERNAME=your_username DB_PASSWORD=your_password
-
Database setup
# Create database (make sure MySQL/PostgreSQL is running) # Then run migrations php artisan migrate # Seed with sample data (optional) php artisan db:seed
-
Build frontend assets
# For development npm run dev # For production npm run build
-
Start the development server
# In one terminal - Laravel server php artisan serve # In another terminal - Vite dev server (for hot reload) npm run dev
-
Access the application
Open your browser and visit:
http://localhost:8000
# Watch for file changes (auto-reload)
npm run dev
# Run Laravel with specific host/port
php artisan serve --host=0.0.0.0 --port=8080
# Clear application cache
php artisan cache:clear
php artisan config:clear
php artisan view:clear
# Run database migrations
php artisan migrate:fresh --seed
# Generate TypeScript types for Laravel routes (if using Ziggy)
php artisan ziggy:generate
If you prefer using Docker:
# Using Laravel Sail
./vendor/bin/sail up -d
# Install dependencies inside container
./vendor/bin/sail composer install
./vendor/bin/sail npm install
# Run migrations
./vendor/bin/sail artisan migrate --seed
# Build assets
./vendor/bin/sail npm run dev
Common Issues:
- Vite connection refused: Make sure both
php artisan serve
andnpm run dev
are running - Database connection error: Verify database credentials in
.env
- Permission errors: Set proper permissions:
chmod -R 775 storage bootstrap/cache
- Missing APP_KEY: Run
php artisan key:generate
- π Server-side Search - Debounced search with query parameter persistence
- π Server-side Pagination - Configurable page sizes with navigation controls
- π Server-side Sorting - Click-to-sort columns with visual indicators
- β Bulk Actions - Select multiple rows and perform batch operations
- ποΈ Bulk Delete - Built-in bulk delete functionality with confirmation dialog
- ποΈ Column Visibility - Show/hide columns with localStorage persistence
- π± Responsive Design - Works on desktop and mobile devices
- π― TypeScript Support - Fully typed with generic interfaces
- π¨ Customizable - Extensible styling and behavior
- Demo
- Tech Stack
- Getting Started
- Features
- Components Overview
- Quick Start
- Frontend Usage
- Backend Implementation
- API Reference
- Examples
- Customization
- Advanced Usage
- License
Component | File | Description |
---|---|---|
DataTable |
resources/js/components/datatable.tsx |
Main table component with all features |
DataTableToolbar |
resources/js/components/datatable-toolbar.tsx |
Search, bulk actions, column visibility |
DataTablePagination |
resources/js/components/datatable-pagination.tsx |
Pagination controls and page size selector |
DataTableColumnHeader |
resources/js/components/datatable-column-header.tsx |
Sortable column headers with sort indicators |
File | Description |
---|---|
resources/js/hooks/use-column-visibility.tsx |
Hook for managing column visibility with localStorage |
resources/js/types/index.d.ts |
TypeScript interfaces and types |
import { DataTable } from '@/components/datatable';
import { ColumnDef } from '@tanstack/react-table';
// Define your data type
interface User {
id: number;
name: string;
email: string;
created_at: string;
}
// Define columns
const columns: (ColumnDef<User> & { enable_sorting?: boolean })[] = [
{
accessorKey: 'id',
header: 'ID',
enable_sorting: true,
},
{
accessorKey: 'name',
header: 'Name',
enable_sorting: true,
},
{
accessorKey: 'email',
header: 'Email',
enable_sorting: true,
},
];
// Use in your page component
function UsersPage({ usersData }: { usersData: PaginatedData<User> }) {
return <DataTable columns={columns} data={usersData.data} paginatedData={usersData} tableKey="users-table" />;
}
<DataTable
columns={columns}
data={usersData.data}
paginatedData={usersData}
activeBulkActions={true}
bulkDelete={{
route: route('users.bulk-delete'),
title: 'Delete Users',
description: 'Are you sure you want to delete the selected users?',
}}
tableKey="users-table"
/>
Columns follow TanStack Table's ColumnDef
interface with an additional enable_sorting
property:
const columns: (ColumnDef<YourDataType> & { enable_sorting?: boolean })[] = [
{
accessorKey: 'field_name',
header: 'Display Name',
enable_sorting: true, // Enable server-side sorting for this column
cell: ({ row }) => {
// Custom cell rendering
return <div>{row.original.field_name}</div>;
},
},
{
header: 'Actions',
accessorKey: 'actions',
enable_sorting: false,
cell: ({ row }) => {
return (
<div className="flex gap-2">
<Button onClick={() => editItem(row.original.id)}>Edit</Button>
<Button onClick={() => deleteItem(row.original.id)}>Delete</Button>
</div>
);
},
},
];
const bulkActions: BulkAction<User>[] = [
{
label: 'Export Selected',
icon: Download,
onClick: (selectedRows) => {
// Handle export
exportUsers(selectedRows);
},
},
{
label: 'Archive Selected',
icon: Archive,
className: 'text-orange-600',
onClick: (selectedRows) => {
// Handle archive
archiveUsers(selectedRows);
},
},
];
<DataTable
// ... other props
bulkActions={bulkActions}
activeBulkActions={true}
/>;
interface DataTableProps<TData, TValue> {
columns: (ColumnDef<TData, TValue> & { enable_sorting?: boolean })[];
data: TData[];
paginatedData?: PaginatedData<TData>;
bulkActions?: BulkAction<TData>[];
bulkDelete?: {
route: string;
title?: string;
description?: string;
};
activeBulkActions?: boolean;
tableKey?: string; // For localStorage column visibility
}
<?php
namespace App\Http\Controllers;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
class UserController extends Controller
{
public function index(Request $request)
{
// Extract query parameters with defaults
$queryParams = request()->only(['search', 'page', 'per_page', 'sort_by', 'sort_dir']) + [
'sort_by' => 'id',
'sort_dir' => 'desc',
'per_page' => 10,
'page' => 1
];
$users = User::query()
// Search functionality
->when($request->search, function ($query, $search) {
$query->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%');
})
// Sorting
->orderBy($queryParams['sort_by'], $queryParams['sort_dir'])
// Pagination
->paginate($queryParams['per_page'])
->withQueryString();
return Inertia::render('users/index', [
'usersData' => UserResource::collection($users)->additional([
'queryParams' => $queryParams,
]),
]);
}
// Bulk delete method
public function bulkDelete(Request $request)
{
$request->validate([
'ids' => 'required|array',
'ids.*' => 'exists:users,id',
]);
User::whereIn('id', $request->ids)->delete();
return redirect()->route('users.index')
->with('success', 'Users deleted successfully');
}
}
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'created_at' => $this->created_at->format('M d, Y'),
'updated_at' => $this->updated_at->format('M d, Y'),
// Add any other fields you need
];
}
}
// routes/web.php
Route::delete('users/bulk-delete', [UserController::class, 'bulkDelete'])->name('users.bulk-delete');
Route::resource('users', UserController::class);
// Main data structure returned from backend
interface PaginatedData<T> {
data: T[];
queryParams: QueryParams;
meta: PaginationMeta;
links: SimplePaginationLinks;
}
// Query parameters for server requests
interface QueryParams {
search?: string;
page?: number;
per_page?: number;
sort_by?: string | null;
sort_dir?: 'asc' | 'desc' | null;
[key: string]: unknown;
}
// Bulk action definition
interface BulkAction<TData> {
label: string;
icon?: LucideIcon | IconType | null;
onClick: (selectedRows: TData[]) => void;
className?: string; // For custom styling
}
// Pagination metadata from Laravel
interface PaginationMeta {
current_page: number;
from: number;
last_page: number;
per_page: number;
to: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
}
// resources/js/pages/users/index.tsx
import { DataTable } from '@/components/datatable';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { ColumnDef } from '@tanstack/react-table';
import { Eye, Pencil, Trash } from 'lucide-react';
const ROLE_COLORS = {
admin: 'border-blue-500 text-blue-500',
manager: 'border-green-500 text-green-500',
user: 'border-gray-500 text-gray-500',
};
const UsersIndex = ({ usersData }: { usersData: PaginatedData<User> }) => {
const handleDeleteUser = (userId: number) => {
router.delete(route('users.destroy', userId));
};
const columns: (ColumnDef<User> & { enable_sorting?: boolean })[] = [
{
accessorKey: 'id',
header: '#ID',
enable_sorting: true,
cell: ({ row }) => <div>#{row.original.id}</div>,
},
{
header: 'Avatar',
accessorKey: 'avatar',
enable_sorting: false,
cell: ({ row }) => (
<Avatar className="size-10">
<AvatarImage src={row.original.avatar} />
<AvatarFallback>{row.original.name.charAt(0)}</AvatarFallback>
</Avatar>
),
},
{
accessorKey: 'name',
header: 'Name',
enable_sorting: true,
cell: ({ row }) => (
<div>
<h2 className="text-base font-semibold">{row.original.name}</h2>
<p className="text-sm text-gray-500">{row.original.email}</p>
</div>
),
},
{
accessorKey: 'role',
header: 'Role',
enable_sorting: true,
cell: ({ row }) => (
<Badge variant="outline" className={`capitalize ${ROLE_COLORS[row.original.role as keyof typeof ROLE_COLORS]}`}>
{row.original.role}
</Badge>
),
},
{
accessorKey: 'created_at',
header: 'Created At',
enable_sorting: true,
},
{
header: 'Actions',
accessorKey: 'actions',
enable_sorting: false,
cell: ({ row }) => (
<div className="flex flex-row gap-0.5">
<Button variant="ghost" size="icon" className="size-8 text-blue-500" asChild>
<Link href={route('users.show', row.original.id)}>
<Eye className="size-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" className="size-8 text-green-500" asChild>
<Link href={route('users.edit', row.original.id)}>
<Pencil className="size-4" />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 text-red-500">
<Trash className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the user "{row.original.name}".
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDeleteUser(row.original.id)} className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
),
},
];
return (
<DataTable
columns={columns}
data={usersData.data}
paginatedData={usersData}
activeBulkActions={true}
bulkDelete={{
route: route('users.bulk-delete'),
title: 'Delete Users',
description: 'Are you sure you want to delete the selected users? This action cannot be undone.',
}}
tableKey="users-table"
/>
);
};
The datatable uses Tailwind CSS classes and follows your existing design system. Key classes can be customized:
- Table container:
.rounded-md.border
- Selected rows:
data-state="selected"
- Toolbar:
.mb-3
- Pagination:
.mt-4
The search is debounced by 500ms and triggers when:
- Input length > 2 characters
- Input is cleared (length = 0)
To customize the debounce timing, modify the useDebouncedCallback
in datatable-toolbar.tsx
:
const handleDebouncedSearch = useDebouncedCallback((value: string) => {
// Search logic
}, 300); // Change from 500ms to 300ms
Column visibility is automatically saved to localStorage using the tableKey
prop. Each table should have a unique key:
<DataTable
tableKey="users-table" // Unique identifier
// ... other props
/>
Default page size options are defined in datatable-pagination.tsx
:
const PER_PAGE_OPTIONS = [10, 15, 20, 25, 30, 40, 50, 100];
Extend the backend search to include more fields:
->when($request->search, function ($query, $search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('phone', 'like', '%' . $search . '%')
->orWhereHas('profile', function ($profile) use ($search) {
$profile->where('bio', 'like', '%' . $search . '%');
});
});
})
Handle relationship sorting:
$allowedSorts = ['id', 'name', 'email', 'created_at', 'profile.company'];
if (in_array($queryParams['sort_by'], $allowedSorts)) {
if (str_contains($queryParams['sort_by'], '.')) {
// Handle relationship sorting
[$relation, $field] = explode('.', $queryParams['sort_by']);
$users->join($relation, 'users.id', '=', "{$relation}.user_id")
->orderBy("{$relation}.{$field}", $queryParams['sort_dir']);
} else {
$users->orderBy($queryParams['sort_by'], $queryParams['sort_dir']);
}
}
Add error handling for failed requests:
// In your page component
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Wrap router calls with error handling
const handleBulkAction = async (selectedRows: User[]) => {
try {
setLoading(true);
setError(null);
await router.delete(route('users.bulk-delete'), {
data: { ids: selectedRows.map((row) => row.id) },
onError: (errors) => {
setError('Failed to delete users. Please try again.');
},
});
} catch (err) {
setError('An unexpected error occurred.');
} finally {
setLoading(false);
}
};
- Use Resource Collections: Always use Laravel Resource Collections to control exactly what data is sent to the frontend
- Limit Searchable Fields: Only search fields that are indexed in your database
- Optimize Queries: Use
select()
to limit returned columns, eager load relationships - Debounced Search: The built-in 500ms debounce prevents excessive API calls
- Column Visibility: Hidden columns still receive data - consider conditional inclusion in your Resource
To extend the datatable functionality:
- Add new features to the appropriate component
- Update TypeScript interfaces in
types/index.d.ts
- Add backend support if needed
- Update this documentation
- Test with the Users example
This project is licensed under the MIT License. See the LICENSE file for details.
β
Commercial use
β
Modification
β
Distribution
β
Private use
β Liability
β Warranty
Built with β€οΈ using React, TanStack Table, Laravel, and Inertia.js