Data tables are a fundamental component of most web applications, but building them with advanced features like inline editing, filtering, pagination, and bulk actions can be complex. In this comprehensive guide, I'll show you how to create feature-rich, interactive data tables using Laravel Livewire Volt and Flux UI.
What We'll Build
By the end of this tutorial, you'll have data tables with:
Inline editing - Edit data directly in table cells
Pagination - Handle large datasets efficiently
Column-wise filtering - Filter data by any column
Bulk selection - Select items across pages
Bulk actions - Perform actions on multiple items
Multiple independent tables - Manage several tables on one page
Prerequisites
Before we begin, make sure you have:
Laravel 10 or higher installed
Livewire 3.x installed
Flux UI installed
Basic understanding of Livewire concepts
Getting Started: Basic Table with Inline Editing
Let's start with a simple table that allows inline editing using Livewire Volt.
Understanding the Challenge with Computed Properties
When working with Livewire Volt, you'll often use computed properties to fetch your data. However, computed properties are read-only, so you can't bind inputs directly to them. Instead, you need to bind to your state and update the database through methods.
Basic Implementation
Here's a basic example with a Product table:
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['products' => []]);
mount(function () {
$this->products = Product::all();
});
$tableData = computed(function () {
return $this->products;
});
$updateProduct = function ($productId, $field, $value) {
$product = Product::find($productId);
if ($product) {
$product->$field = $value;
$product->save();
// Refresh the products collection
$this->products = Product::all();
}
};
?>
<flux:table>
<flux:table.columns>
<flux:table.column>Name</flux:table.column>
<flux:table.column>Price</flux:table.column>
<flux:table.column>Stock</flux:table.column>
</flux:table.columns>
<flux:rows>
@foreach($this->tableData as $product)
<flux:row wire:key="product-{{ $product->id }}">
<flux:table.cell>
<input
type="text"
value="{{ $product->name }}"
wire:change="updateProduct({{ $product->id }}, 'name', $event.target.value)"
class="w-full"
/>
</flux:table.cell>
<flux:table.cell>
<input
type="number"
step="0.01"
value="{{ $product->price }}"
wire:change="updateProduct({{ $product->id }}, 'price', $event.target.value)"
class="w-full"
/>
</flux:table.cell>
<flux:table.cell>
<input
type="number"
value="{{ $product->stock }}"
wire:change="updateProduct({{ $product->id }}, 'stock', $event.target.value)"
class="w-full"
/>
</flux:table.cell>
</flux:row>
@endforeach
</flux:rows>
</flux:table>
Key Points
Use
wire:key: Always addwire:key="product-{{ $product->id }}"to help Livewire track each rowUse product IDs: Reference
$product->idin your update methods to ensure you're modifying the correct recordChoose your update strategy: Use
wire:changefor updates on blur, orwire:model.livefor real-time updates
Adding Pagination
Handling large datasets requires pagination. With Livewire's built-in pagination and Flux UI's table component, this becomes straightforward.
<?php
use App\Models\Product;
use function Livewire\Volt\{computed, usesPagination};
usesPagination();
$products = computed(function () {
return Product::paginate(10);
});
$updateProduct = function ($productId, $field, $value) {
$product = Product::find($productId);
if ($product) {
$product->update([$field => $value]);
}
};
?>
<flux:table :paginate="$this->products">
<flux:table.columns>
<flux:table.column>Name</flux:table.column>
<flux:table.column>Price</flux:table.column>
<flux:table.column>Stock</flux:table.column>
</flux:table.columns>
<flux:rows>
@foreach($this->products as $product)
<flux:row wire:key="product-{{ $product->id }}">
<flux:table.cell>
<input
type="text"
value="{{ $product->name }}"
wire:change="updateProduct({{ $product->id }}, 'name', $event.target.value)"
class="w-full"
/>
</flux:table.cell>
<!-- Other cells... -->
</flux:row>
@endforeach
</flux:rows>
</flux:table>
Important Notes on Pagination
When working with paginated data:
Don't use array indices - Since pagination changes which records are displayed, you can't rely on array indices
Always use model IDs - Reference
$product->idto identify which record to updateUse Flux's
:paginateattribute - Pass the paginated collection directly to the table component
Multiple Tables on One Page
Often, you'll need to display multiple tables on the same page. The key is using named paginators to keep each table's pagination independent.
<?php
use App\Models\Product;
use App\Models\Order;
use App\Models\Customer;
use function Livewire\Volt\{computed, usesPagination};
usesPagination();
$products = computed(function () {
return Product::paginate(10, ['*'], 'productsPage');
});
$orders = computed(function () {
return Order::paginate(10, ['*'], 'ordersPage');
});
$customers = computed(function () {
return Customer::paginate(10, ['*'], 'customersPage');
});
?>
<div class="space-y-8">
<!-- Products Table -->
<div>
<h2 class="text-xl font-bold mb-4">Products</h2>
<flux:table :paginate="$this->products">
<!-- Table content -->
</flux:table>
</div>
<!-- Orders Table -->
<div>
<h2 class="text-xl font-bold mb-4">Orders</h2>
<flux:table :paginate="$this->orders">
<!-- Table content -->
</flux:table>
</div>
<!-- Customers Table -->
<div>
<h2 class="text-xl font-bold mb-4">Customers</h2>
<flux:table :paginate="$this->customers">
<!-- Table content -->
</flux:table>
</div>
</div>
The third parameter in paginate() is the page name. This ensures each table maintains independent pagination state, resulting in URLs like: ?productsPage=2&ordersPage=1&customersPage=3
Column-Wise Filtering
Column-wise filtering allows users to narrow down data by specific criteria. Let's implement filters for each table column.
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed, usesPagination};
usesPagination();
state([
'productFilters' => [
'name' => '',
'price_min' => '',
'price_max' => '',
'stock_min' => '',
],
]);
$products = computed(function () {
$query = Product::query();
if (!empty($this->productFilters['name'])) {
$query->where('name', 'like', "%{$this->productFilters['name']}%");
}
if (!empty($this->productFilters['price_min'])) {
$query->where('price', '>=', $this->productFilters['price_min']);
}
if (!empty($this->productFilters['price_max'])) {
$query->where('price', '<=', $this->productFilters['price_max']);
}
if (!empty($this->productFilters['stock_min'])) {
$query->where('stock', '>=', $this->productFilters['stock_min']);
}
return $query->paginate(10, ['*'], 'productsPage');
});
$resetProductFilters = function () {
$this->productFilters = [
'name' => '',
'price_min' => '',
'price_max' => '',
'stock_min' => '',
];
$this->resetPage('productsPage');
};
?>
<div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Products</h2>
<button
wire:click="resetProductFilters"
class="text-sm text-blue-600 hover:text-blue-800"
>
Clear Filters
</button>
</div>
<flux:table :paginate="$this->products">
<flux:table.columns>
<flux:table.column>
<div class="space-y-2">
<div class="font-semibold">Name</div>
<flux:input
wire:model.live.debounce.300ms="productFilters.name"
placeholder="Filter by name..."
size="sm"
/>
</div>
</flux:table.column>
<flux:table.column>
<div class="space-y-2">
<div class="font-semibold">Price</div>
<div class="flex gap-1">
<flux:input
wire:model.live.debounce.300ms="productFilters.price_min"
type="number"
step="0.01"
placeholder="Min"
size="sm"
/>
<flux:input
wire:model.live.debounce.300ms="productFilters.price_max"
type="number"
step="0.01"
placeholder="Max"
size="sm"
/>
</div>
</div>
</flux:table.column>
<flux:table.column>
<div class="space-y-2">
<div class="font-semibold">Stock</div>
<flux:input
wire:model.live.debounce.300ms="productFilters.stock_min"
type="number"
placeholder="Min stock..."
size="sm"
/>
</div>
</flux:table.column>
</flux:table.columns>
<flux:rows>
@foreach($this->products as $product)
<flux:row wire:key="product-{{ $product->id }}">
<!-- Table cells -->
</flux:row>
@endforeach
</flux:rows>
</flux:table>
</div>
Filter Best Practices
Use debouncing: Add
.debounce.300msto prevent excessive queriesSeparate filter state: Keep filters in their own state array for each table
Provide clear filters button: Make it easy for users to reset all filters
Support range filters: For numeric columns, allow min/max filtering
Bulk Selection and Actions
One of the most powerful features is bulk selection across pages. This allows users to select items they can't even see on the current page and perform actions on all of them at once.
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed, usesPagination};
usesPagination();
state([
'selectedProducts' => [],
'selectAllProducts' => false,
'productFilters' => [
'name' => '',
'price_min' => '',
'price_max' => '',
'stock_min' => '',
],
]);
$products = computed(function () {
$query = Product::query();
// Apply filters
if (!empty($this->productFilters['name'])) {
$query->where('name', 'like', "%{$this->productFilters['name']}%");
}
if (!empty($this->productFilters['price_min'])) {
$query->where('price', '>=', $this->productFilters['price_min']);
}
if (!empty($this->productFilters['price_max'])) {
$query->where('price', '<=', $this->productFilters['price_max']);
}
if (!empty($this->productFilters['stock_min'])) {
$query->where('stock', '>=', $this->productFilters['stock_min']);
}
return $query->paginate(10, ['*'], 'productsPage');
});
// Get ALL product IDs matching current filters (for select all)
$allProductIds = computed(function () {
$query = Product::query();
// Apply same filters
if (!empty($this->productFilters['name'])) {
$query->where('name', 'like', "%{$this->productFilters['name']}%");
}
if (!empty($this->productFilters['price_min'])) {
$query->where('price', '>=', $this->productFilters['price_min']);
}
if (!empty($this->productFilters['price_max'])) {
$query->where('price', '<=', $this->productFilters['price_max']);
}
if (!empty($this->productFilters['stock_min'])) {
$query->where('stock', '>=', $this->productFilters['stock_min']);
}
return $query->pluck('id')->toArray();
});
$toggleSelectAllProducts = function () {
if ($this->selectAllProducts) {
// Deselect all
$this->selectedProducts = [];
$this->selectAllProducts = false;
} else {
// Select all matching current filters
$this->selectedProducts = $this->allProductIds;
$this->selectAllProducts = true;
}
};
$bulkDeleteProducts = function () {
if (count($this->selectedProducts) > 0) {
Product::whereIn('id', $this->selectedProducts)->delete();
$this->selectedProducts = [];
$this->selectAllProducts = false;
session()->flash('message', 'Selected products deleted successfully!');
}
};
?>
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4">
<h2 class="text-xl font-bold">Products</h2>
@if(count($this->selectedProducts) > 0)
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">
{{ count($this->selectedProducts) }} selected
</span>
<button
wire:click="bulkDeleteProducts"
wire:confirm="Are you sure you want to delete {{ count($this->selectedProducts) }} products?"
class="px-3 py-1 bg-red-500 text-white rounded text-sm hover:bg-red-600"
>
Delete Selected
</button>
</div>
@endif
</div>
</div>
<flux:table :paginate="$this->products">
<flux:table.columns>
<flux:table.column class="w-12">
<input
type="checkbox"
wire:click="toggleSelectAllProducts"
:checked="$this->selectAllProducts"
class="rounded"
/>
</flux:table.column>
<flux:table.column>Name</flux:table.column>
<flux:table.column>Price</flux:table.column>
<flux:table.column>Stock</flux:table.column>
</flux:table.columns>
<flux:rows>
@foreach($this->products as $product)
<flux:row wire:key="product-{{ $product->id }}">
<flux:table.cell>
<input
type="checkbox"
wire:model.live="selectedProducts"
value="{{ $product->id }}"
class="rounded"
/>
</flux:table.cell>
<flux:table.cell>{{ $product->name }}</flux:table.cell>
<flux:table.cell>${{ number_format($product->price, 2) }}</flux:table.cell>
<flux:table.cell>{{ $product->stock }}</flux:table.cell>
</flux:row>
@endforeach
</flux:rows>
</flux:table>
@if($this->selectAllProducts && count($this->selectedProducts) > 0)
<div class="mt-2 p-3 bg-blue-50 text-blue-700 rounded text-sm">
All {{ count($this->selectedProducts) }} products matching your filters are selected.
</div>
@endif
</div>
How Bulk Selection Works
Selection State: Store selected IDs in an array
Select All Computation: Fetch ALL IDs matching current filters (not just current page)
Toggle Functionality: The header checkbox selects/deselects all matching items
Bulk Actions: Actions use
whereIn('id', $selectedIds)to operate on all selected itemsVisual Feedback: Show users how many items are selected
Complete Example: Multiple Tables with All Features
Here's a complete implementation combining everything we've covered:
<?php
use App\Models\Product;
use App\Models\Order;
use function Livewire\Volt\{state, computed, usesPagination};
usesPagination();
state([
// Product filters
'productFilters' => [
'name' => '',
'price_min' => '',
'price_max' => '',
'stock_min' => '',
],
// Order filters
'orderFilters' => [
'order_number' => '',
'status' => '',
'total_min' => '',
'total_max' => '',
],
// Selection state
'selectedProducts' => [],
'selectedOrders' => [],
'selectAllProducts' => false,
'selectAllOrders' => false,
]);
// Products with filters
$products = computed(function () {
$query = Product::query();
if (!empty($this->productFilters['name'])) {
$query->where('name', 'like', "%{$this->productFilters['name']}%");
}
if (!empty($this->productFilters['price_min'])) {
$query->where('price', '>=', $this->productFilters['price_min']);
}
if (!empty($this->productFilters['price_max'])) {
$query->where('price', '<=', $this->productFilters['price_max']);
}
if (!empty($this->productFilters['stock_min'])) {
$query->where('stock', '>=', $this->productFilters['stock_min']);
}
return $query->paginate(10, ['*'], 'productsPage');
});
// Orders with filters
$orders = computed(function () {
$query = Order::query();
if (!empty($this->orderFilters['order_number'])) {
$query->where('order_number', 'like', "%{$this->orderFilters['order_number']}%");
}
if (!empty($this->orderFilters['status'])) {
$query->where('status', $this->orderFilters['status']);
}
if (!empty($this->orderFilters['total_min'])) {
$query->where('total', '>=', $this->orderFilters['total_min']);
}
if (!empty($this->orderFilters['total_max'])) {
$query->where('total', '<=', $this->orderFilters['total_max']);
}
return $query->paginate(10, ['*'], 'ordersPage');
});
// Get all product IDs for bulk selection
$allProductIds = computed(function () {
$query = Product::query();
if (!empty($this->productFilters['name'])) {
$query->where('name', 'like', "%{$this->productFilters['name']}%");
}
if (!empty($this->productFilters['price_min'])) {
$query->where('price', '>=', $this->productFilters['price_min']);
}
if (!empty($this->productFilters['price_max'])) {
$query->where('price', '<=', $this->productFilters['price_max']);
}
if (!empty($this->productFilters['stock_min'])) {
$query->where('stock', '>=', $this->productFilters['stock_min']);
}
return $query->pluck('id')->toArray();
});
// Bulk actions
$toggleSelectAllProducts = function () {
if ($this->selectAllProducts) {
$this->selectedProducts = [];
$this->selectAllProducts = false;
} else {
$this->selectedProducts = $this->allProductIds;
$this->selectAllProducts = true;
}
};
$bulkDeleteProducts = function () {
if (count($this->selectedProducts) > 0) {
Product::whereIn('id', $this->selectedProducts)->delete();
$this->selectedProducts = [];
$this->selectAllProducts = false;
session()->flash('message', 'Selected products deleted successfully!');
}
};
// Reset filters
$resetProductFilters = function () {
$this->productFilters = [
'name' => '',
'price_min' => '',
'price_max' => '',
'stock_min' => '',
];
$this->resetPage('productsPage');
};
?>
<div class="space-y-8">
@if (session()->has('message'))
<div class="p-4 bg-green-100 text-green-700 rounded">
{{ session('message') }}
</div>
@endif
<!-- Products Table -->
<div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4">
<h2 class="text-xl font-bold">Products</h2>
@if(count($this->selectedProducts) > 0)
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">
{{ count($this->selectedProducts) }} selected
</span>
<button
wire:click="bulkDeleteProducts"
wire:confirm="Are you sure?"
class="px-3 py-1 bg-red-500 text-white rounded text-sm"
>
Delete Selected
</button>
</div>
@endif
</div>
<button
wire:click="resetProductFilters"
class="text-sm text-blue-600"
>
Clear Filters
</button>
</div>
<flux:table :paginate="$this->products">
<flux:table.columns>
<flux:table.column class="w-12">
<input
type="checkbox"
wire:click="toggleSelectAllProducts"
:checked="$this->selectAllProducts"
class="rounded"
/>
</flux:table.column>
<flux:table.column>
<div class="space-y-2">
<div class="font-semibold">Name</div>
<flux:input
wire:model.live.debounce.300ms="productFilters.name"
placeholder="Filter..."
size="sm"
/>
</div>
</flux:table.column>
<flux:table.column>
<div class="space-y-2">
<div class="font-semibold">Price</div>
<div class="flex gap-1">
<flux:input
wire:model.live.debounce.300ms="productFilters.price_min"
type="number"
placeholder="Min"
size="sm"
/>
<flux:input
wire:model.live.debounce.300ms="productFilters.price_max"
type="number"
placeholder="Max"
size="sm"
/>
</div>
</div>
</flux:table.column>
<flux:table.column>Stock</flux:table.column>
</flux:table.columns>
<flux:rows>
@foreach($this->products as $product)
<flux:row wire:key="product-{{ $product->id }}">
<flux:table.cell>
<input
type="checkbox"
wire:model.live="selectedProducts"
value="{{ $product->id }}"
class="rounded"
/>
</flux:table.cell>
<flux:table.cell>{{ $product->name }}</flux:table.cell>
<flux:table.cell>${{ number_format($product->price, 2) }}</flux:table.cell>
<flux:table.cell>{{ $product->stock }}</flux:table.cell>
</flux:row>
@endforeach
</flux:rows>
</flux:table>
</div>
</div>
Performance Considerations
When building data tables with these features, keep these performance tips in mind:
Use database indexing: Add indexes to columns you'll filter on frequently
Debounce filter inputs: Prevent excessive queries with
.debounce.300msLimit bulk operations: Consider adding limits on how many items can be selected at once
Use eager loading: If displaying relationships, use
with()to avoid N+1 queriesCache computed properties: For expensive queries, consider caching results
Conclusion
Building advanced data tables with Laravel Livewire Volt and Flux UI gives you powerful, interactive components with minimal JavaScript. The combination of inline editing, filtering, pagination, and bulk actions creates a professional user experience.
Key takeaways:
Use computed properties for read-only data display
Bind inputs to state or use event handlers for updates
Use named paginators for multiple tables on one page
Implement filters with debouncing to reduce server load
Store selected IDs for bulk operations across pages
With these patterns, you can build robust data management interfaces that scale with your application's needs. The examples in this article provide a solid foundation that you can extend with sorting, custom filters, export functionality, and more.
Happy coding!