Laravel Livewire Data Tables: Complete Guide to Inline Editing, Filtering & Bulk Actions with Flux UI and Laravel Volt

Laravel Livewire Data Tables: Complete Guide to Inline Editing, Filtering & Bulk Actions with Flux UI and Laravel Volt

Abishek R Srikaanth

Sun, Jan 18, 2026

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 add wire:key="product-{{ $product->id }}" to help Livewire track each row

  • Use product IDs: Reference $product->id in your update methods to ensure you're modifying the correct record

  • Choose your update strategy: Use wire:change for updates on blur, or wire:model.live for 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:

  1. Don't use array indices - Since pagination changes which records are displayed, you can't rely on array indices

  2. Always use model IDs - Reference $product->id to identify which record to update

  3. Use Flux's :paginate attribute - 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.300ms to prevent excessive queries

  • Separate 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

  1. Selection State: Store selected IDs in an array

  2. Select All Computation: Fetch ALL IDs matching current filters (not just current page)

  3. Toggle Functionality: The header checkbox selects/deselects all matching items

  4. Bulk Actions: Actions use whereIn('id', $selectedIds) to operate on all selected items

  5. Visual 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:

  1. Use database indexing: Add indexes to columns you'll filter on frequently

  2. Debounce filter inputs: Prevent excessive queries with .debounce.300ms

  3. Limit bulk operations: Consider adding limits on how many items can be selected at once

  4. Use eager loading: If displaying relationships, use with() to avoid N+1 queries

  5. Cache 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!

CONTACT US

Get in touch and let us know how we can help

Name *
Email *
Phone *
WorkDoneRight Logo
Copyright 2026 WorkDoneRight | Privacy Policy