Every Livewire app eventually grows a table. It starts as a @foreach over 20 rows and ends up the centerpiece of an admin panel: 50,000 orders, six filters, a search box, bulk-actions, inline editing, and a "save my filters" requirement. At that point, most teams reach for livewire-tables or livewire-powergrid — and inherit a package with its own opinions, its own DSL, and 2,000 lines of code to debug when something goes wrong.
You probably don't need either. Flux's <flux:table> (free), Flux's <flux:pagination> (free), and Livewire's WithPagination trait give you 90% of what those packages offer — in code you already understand, with no extra dependency to upgrade. This guide builds that stack end to end: a real ListOrders component with sortable columns, a debounced search, multi-select filters, bulk delete, inline editing, URL state persistence, two paginators on one page, and a Pest 4 test suite that proves the lot.
Render the basic flux:table with paginated rows#
Start with the dumb version: a flux:table that paginates an Eloquent query. This is the skeleton everything else hangs from, and it's worth keeping it stupid until you've seen it render — the rest of the guide layers complexity onto this exact shape. Flux's table is purely presentational, so the pagination, ordering, and filtering all happen server-side on the Eloquent builder; the Blade just lays out the columns and rows.
Generate the component and template with Artisan, then wire it to a route or Folio page. If you're starting from scratch and want a refresher on routing without controllers, the Laravel 12 Folio + Sushi developer-site walkthrough explains why file-based routes pair well with single-purpose Livewire screens like this one.
php artisan make:livewire ListOrders
// app/Livewire/ListOrders.php
namespace App\Livewire;
use App\Models\Order;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;
class ListOrders extends Component
{
use WithPagination;
#[Computed]
public function orders()
{
return Order::query()
->with('customer')
->latest('placed_at')
->paginate(perPage: 25);
}
public function render()
{
return view('livewire.list-orders');
}
}
{{-- resources/views/livewire/list-orders.blade.php --}}
<div>
<flux:table :paginate="$this->orders">
<flux:table.columns>
<flux:table.column>Customer</flux:table.column>
<flux:table.column>Placed</flux:table.column>
<flux:table.column>Status</flux:table.column>
<flux:table.column>Amount</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($this->orders as $order)
<flux:table.row wire:key="order-{{ $order->id }}">
<flux:table.cell>{{ $order->customer->name }}</flux:table.cell>
<flux:table.cell>{{ $order->placed_at->format('M j, g:i A') }}</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$order->status_color">{{ $order->status->label() }}</flux:badge>
</flux:table.cell>
<flux:table.cell variant="strong">£{{ number_format($order->amount, 2) }}</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</div>
A few non-obvious bits worth pointing out. $this->orders uses the #[Computed] attribute so the paginator instance is built once per request and shared between the :paginate prop, the @foreach, and any later assertion. The wire:key="order-{{ $order->id }}" is there from line one — not as polish but as a correctness requirement, which we'll come back to in the wire:key step. And with('customer') is eager-loading the relationship that the cell template touches; skip it and you get an N+1 the moment you hit page two.
Add sortable column headers with sort and direction state#
Server-driven sorting on a Flux table is a two-piece pattern: the column declares it's sortable, and the component owns the state of which field is being sorted and in which direction. Click a sortable header and Livewire dispatches a method that toggles direction on a repeat click or switches to the new column with an ascending default. The Eloquent query then reads those two properties and applies orderBy before paginating.
The Flux column accepts three relevant props: sortable marks it as clickable, :sorted is a boolean for "this is the active column", and :direction mirrors the current direction. Pair that with a sort($field) method that flips state, and you have the full pattern.
// app/Livewire/ListOrders.php
use Livewire\Attributes\Url;
class ListOrders extends Component
{
use WithPagination;
#[Url(as: 'sort', except: 'placed_at')]
public string $sortField = 'placed_at';
#[Url(as: 'dir', except: 'desc')]
public string $sortDirection = 'desc';
public function sort(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
$this->resetPage();
}
#[Computed]
public function orders()
{
return Order::query()
->with('customer')
->orderBy($this->sortField, $this->sortDirection)
->paginate(perPage: 25);
}
}
<flux:table.column
sortable
:sorted="$sortField === 'placed_at'"
:direction="$sortDirection"
wire:click="sort('placed_at')"
>
Placed
</flux:table.column>
<flux:table.column
sortable
:sorted="$sortField === 'amount'"
:direction="$sortDirection"
wire:click="sort('amount')"
>
Amount
</flux:table.column>
Two pieces of polish to call out. $this->resetPage() inside sort() is critical — without it, a user sitting on page 7 of "placed desc" will switch to "amount asc" and stay on page 7 of a different result set, which usually means an empty page. And the #[Url] attribute (with the as: alias and except: default-suppression) keeps the URL clean on the default sort while still surfacing custom sorts as shareable links. The same #[Url] attribute is the pattern we'll lean on heavily when we get to the URL-state step.
Add a debounced search filter above the table#
A search input above the table is the most-requested feature in any admin UI, and Livewire's wire:model.live with debounce makes it a four-line addition — but only if you remember to reset the paginator. Without that reset, the user types "acme", expects to see 12 matches, but instead sees an empty page-7 view because the filter shrank the result set below the previous page.
The trick is the updatedSearch lifecycle hook. Whenever the $search property changes, Livewire fires updatedSearch(), which calls resetPage(), and the freshly-filtered query renders from page one. Pair that with a 250ms debounce on the input and you get type-as-you-go search without spamming the server.
#[Url(as: 'q', except: '')]
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
#[Computed]
public function orders()
{
return Order::query()
->with('customer')
->when($this->search, function ($query) {
$query->whereHas('customer', function ($q) {
$q->where('name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%");
})->orWhere('reference', 'like', "%{$this->search}%");
})
->orderBy($this->sortField, $this->sortDirection)
->paginate(perPage: 25);
}
<div class="mb-4 flex items-center gap-3">
<flux:input
wire:model.live.debounce.250ms="search"
icon="magnifying-glass"
placeholder="Search orders, customer, email..."
clearable
/>
</div>
Two real-world details. The whereHas plus orWhere('reference', …) shape lets users search by either the customer's name or the order's own reference number — that's the actual ask from most admins, not "search a single column". And the 250ms debounce is a sweet spot for keyboard input; drop to 100ms and you'll hammer your database, push to 500ms and the UI feels sluggish. If you need to push that into hundreds-of-thousands-of-rows territory, switch the like clauses for a full-text index or Meilisearch — the rest of this component stays exactly the same.
Add multi-select filters in a flux:dropdown#
Single-string search covers free-form text, but the productive admin UI is multi-select: "show me orders that are paid OR refunded, from these three regions, placed by these five customers". Each filter ends up as a whereIn() clause, and the component holds the filter state in an associative array so a single applyFilters() helper can compose them onto the Eloquent builder.
A flux:dropdown containing flux:checkbox.group is the cleanest UX for this, because it collapses on click-away and shows the selected count on the trigger button. The brief here was "users will tick five things and expect them to all apply at once" — so we deliberately don't use wire:model.live on the checkboxes; we update on dropdown close to avoid five separate round-trips.
#[Url(as: 'status')]
public array $statusFilter = [];
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function clearFilters(): void
{
$this->reset(['search', 'statusFilter']);
$this->resetPage();
}
#[Computed]
public function orders()
{
return Order::query()
->with('customer')
->when($this->search, fn ($q) => $q->where(/* ... */))
->when($this->statusFilter, fn ($q) => $q->whereIn('status', $this->statusFilter))
->orderBy($this->sortField, $this->sortDirection)
->paginate(perPage: 25);
}
<flux:dropdown>
<flux:button icon-trailing="chevron-down">
Status
@if (count($statusFilter))
<flux:badge size="sm" color="zinc" inset="top bottom">{{ count($statusFilter) }}</flux:badge>
@endif
</flux:button>
<flux:menu>
<flux:checkbox.group wire:model.live="statusFilter">
<flux:checkbox value="pending" label="Pending" />
<flux:checkbox value="paid" label="Paid" />
<flux:checkbox value="refunded" label="Refunded" />
<flux:checkbox value="failed" label="Failed" />
</flux:checkbox.group>
</flux:menu>
</flux:dropdown>
@if ($search || count($statusFilter))
<flux:button variant="ghost" wire:click="clearFilters">Clear filters</flux:button>
@endif
If you find yourself writing the same filter chain on three or four tables, hoist the filter array and the applyFilters() helper into a Livewire 4 form object using the #[Validate] attribute — the validation rules ride along with the filter state, the component stays small, and you get a single test target.
Wire up bulk-action checkboxes and a bulk delete#
Bulk actions are where most home-grown tables fall over, because the UX needs three states the developer didn't plan for: nothing selected, some rows selected, all rows on this page selected, and "all matching rows across all pages" selected. We'll cover the first three here (the fourth is a one-line addition once the query is in #[Computed]).
Hold the selection as an array of primary keys on the component. Use a header checkbox that toggles all visible rows, per-row checkboxes that toggle individual IDs, and a bulkDelete() action that confirms and runs inside a database transaction. Critically, every row already has a wire:key (we set it in step one) — bulk actions are the place where a missing or duplicate wire:key will cause Livewire to delete the wrong record.
use Illuminate\Support\Facades\DB;
public array $selected = [];
public bool $selectAllOnPage = false;
public function updatedSelectAllOnPage(bool $value): void
{
$this->selected = $value ? $this->orders->pluck('id')->map(fn ($id) => (string) $id)->all() : [];
}
public function bulkDelete(): void
{
$this->authorize('bulkDelete', Order::class);
DB::transaction(function () {
Order::whereIn('id', $this->selected)->delete();
});
$count = count($this->selected);
$this->selected = [];
$this->selectAllOnPage = false;
unset($this->orders); // bust the computed cache
Flux::toast(variant: 'success', text: "{$count} orders deleted.");
}
@if (count($selected))
<div class="mb-3 flex items-center justify-between rounded-lg bg-zinc-100 px-4 py-2 dark:bg-zinc-800">
<span class="text-sm">{{ count($selected) }} selected</span>
<flux:button
variant="danger"
size="sm"
wire:click="bulkDelete"
wire:confirm="Delete {{ count($selected) }} orders permanently?"
>
Delete selected
</flux:button>
</div>
@endif
<flux:table.column>
<flux:checkbox wire:model.live="selectAllOnPage" />
</flux:table.column>
{{-- inside the row loop --}}
<flux:table.cell>
<flux:checkbox wire:model.live="selected" value="{{ $order->id }}" />
</flux:table.cell>
Three production hardening points. wire:confirm triggers the native browser confirm dialog before the method fires — sufficient for "you're about to delete 47 things" but swap it for a flux:modal if you need richer confirm UI. $this->authorize() runs your existing policy, so role-based gates work without per-row checks. And unset($this->orders) invalidates the computed-property cache; without it, the post-delete render would show stale rows for a heartbeat before the next query.
Add inline editing for a single cell#
Inline editing inside a data table is the feature that turns "read-only report" into "actual admin tool". The pattern is small: track which row+column is currently editable on a single $editing string, render either the display value or an input depending on whether that string matches the current cell, and persist on blur or Enter.
The interaction model worth defending here is "one cell at a time" — multi-cell inline-edit feels powerful but breaks horribly with concurrent updates, validation errors, and pagination. Keep it boring and your users will thank you.
public ?string $editing = null;
public string $editValue = '';
public function edit(int $orderId, string $field): void
{
$this->editing = "{$orderId}.{$field}";
$this->editValue = (string) Order::find($orderId)->{$field};
}
public function saveEdit(): void
{
if (! $this->editing) {
return;
}
[$id, $field] = explode('.', $this->editing);
Order::findOrFail($id)
->update([$field => $this->editValue]);
$this->editing = null;
$this->editValue = '';
unset($this->orders);
}
public function cancelEdit(): void
{
$this->editing = null;
$this->editValue = '';
}
<flux:table.cell>
@if ($editing === "{$order->id}.reference")
<flux:input
wire:model="editValue"
wire:keydown.enter="saveEdit"
wire:keydown.escape="cancelEdit"
wire:blur="saveEdit"
size="sm"
autofocus
/>
@else
<button
type="button"
wire:click="edit({{ $order->id }}, 'reference')"
class="cursor-text rounded px-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
{{ $order->reference }}
</button>
@endif
</flux:table.cell>
If you want a richer editor experience — typeahead, validation pop-overs, multi-field forms — promote the inline edit to a Flux modal and share state between Alpine and Livewire. The Livewire 4 + Alpine.js $wire and @entangle guide covers exactly that handshake, including the gotcha where stale Alpine state survives a modal close.
Persist filter state in the URL via wire:query-string#
Bookmarkable URLs are the small detail that turns a table from "good" to "indispensable". Sales pings you a Slack link, you click it, and the same filters, same sort, same page load up — no hand-typing query parameters. Livewire 4's #[Url] attribute on properties handles this almost for free; we've already used it on $sortField, $sortDirection, $search, and $statusFilter, but the as: aliases and except: defaults are worth a closer look.
#[Url(as: 'q', except: '')]
public string $search = '';
#[Url(as: 'sort', except: 'placed_at')]
public string $sortField = 'placed_at';
#[Url(as: 'dir', except: 'desc')]
public string $sortDirection = 'desc';
#[Url(as: 'status')]
public array $statusFilter = [];
The as: alias gives you a short, readable query string (?q=acme&sort=amount&dir=asc) instead of ?search=acme&sortField=amount&sortDirection=asc. The except: value tells Livewire "don't surface this in the URL when it equals the default" — so the URL stays clean on first load and only grows entries for non-default state. The combined effect is bookmarkable, sharable, and survives a hard refresh without ever leaking implementation detail. There's a related-but-orthogonal pattern for preserving navigation state across full-page transitions in the wire:navigate prefetch SPA-feel guide — useful when the table is one stop in a multi-page admin flow.
Run two tables on one page with named paginators#
The moment a single screen needs to paginate two collections — say, "open orders" and "draft invoices" — the default ?page= query string becomes ambiguous. Both tables hijack the same parameter, so clicking page two of orders flips invoices to page two as well. Livewire's named-paginator support fixes this with a single argument to paginate().
Pass pageName: 'orders' and pageName: 'invoices' to two separate paginate() calls inside the same component (or two components on the same page) and Livewire writes them to distinct query parameters: ?page=2&invoices-page=2. Lifecycle hooks like resetPage() accept the same name argument.
class CustomerDashboard extends Component
{
use WithPagination;
#[Computed]
public function orders()
{
return Order::query()
->where('customer_id', $this->customerId)
->latest('placed_at')
->paginate(perPage: 10, pageName: 'orders');
}
#[Computed]
public function invoices()
{
return Invoice::query()
->where('customer_id', $this->customerId)
->latest('issued_at')
->paginate(perPage: 10, pageName: 'invoices');
}
public function updatedSearchInvoices(): void
{
$this->resetPage(pageName: 'invoices');
}
}
<flux:heading size="lg">Orders</flux:heading>
<flux:table :paginate="$this->orders">
{{-- columns and rows --}}
</flux:table>
<flux:heading size="lg" class="mt-10">Invoices</flux:heading>
<flux:table :paginate="$this->invoices">
{{-- columns and rows --}}
</flux:table>
If you find yourself with three or more tables on one screen, that's a real signal that the screen is doing too much and the user would be better served by tabs. But two tables — typical for a customer detail view or a project dashboard — is exactly the sweet spot named paginators were built for. The result behaves identically to two separate pages of pagination, but on one route, with one Livewire component, and a clean URL like /customers/42?page=2&invoices-page=3.
Polish the loading state with flux:placeholder and wire:loading#
The instant a search request fires, the table redraws — and Livewire's default behavior is "show the old rows until the new ones arrive". For any query slower than ~200ms, that creates a confusing flicker: the page looks unchanged, then suddenly the data has switched. A loading placeholder turns that into an obvious "thinking..." beat.
Two directives do the work. wire:loading.delay on a wrapper toggles visibility after 200ms (so quick requests don't flash), and flux:placeholder (free) renders a skeleton row with the correct width.
<div wire:loading.delay.class="opacity-40 pointer-events-none">
<flux:table :paginate="$this->orders">
{{-- ... --}}
</flux:table>
</div>
<div wire:loading.delay class="absolute inset-x-0 top-1/2 flex justify-center">
<flux:icon.loading class="size-6 text-zinc-500" />
</div>
For first-load skeletons (when the page loads with the table empty and the query is firing for the first time), wrap the whole table in a Livewire 4 lazy island. The Livewire 4 islands and lazy-load guide covers the <wire:island lazy> pattern, which renders a flux:placeholder skeleton on the server while the actual query streams down. For a 50,000-row table with five joins, that's the difference between a 1.4s perceived load and an instant pop-in followed by a 1.4s background fetch.
Avoid the wire:key trap and N+1 query pitfalls#
Two production landmines kill more Flux tables than any other bug class. The first is the wire:key trap: Livewire's DOM diffing engine relies on stable keys to match old elements to new ones across re-renders, and without them, bulk-edit silently writes to the wrong row after pagination. The second is the N+1 query, which doesn't break the UI but does turn a 25-row table into 76 SQL queries.
For wire:key, the rule is unbreakable: every row that comes out of a @foreach over Eloquent results gets wire:key="model-{{ $record->id }}". Prefix with the model name so two tables on the same page don't collide. Never use $loop->index — when row 3 gets deleted, the index of row 4 becomes 3, and Livewire happily binds the now-different row to the old DOM node, including any unsaved inline-edit state. The same DOM-diff principles inform wire:sort drag-and-drop reordering, where stale keys cause the dragged item to snap back to the wrong position.
{{-- correct --}}
<flux:table.row wire:key="order-{{ $order->id }}">
{{-- wrong — index changes when rows are deleted or reordered --}}
<flux:table.row wire:key="row-{{ $loop->index }}">
For N+1, eager-load anything the cells touch. with(['customer', 'lineItems']) runs one query for orders, one for customers, one for line items, and Laravel hydrates the relationships in PHP. Without it, every rendered row fires its own SELECT — for 25 rows with two relationships, that's 51 queries instead of 3. Watch the query count in Telescope, Pulse, or Debugbar — pick the right one and the regression jumps out immediately. If you can't eager-load (truly dynamic columns), lean on a LazyCollection and a chunk() strategy instead.
Test the table with Pest 4 and Livewire helpers#
A table this rich has too many interactions to verify by hand on every change. Pest 4 plus the Livewire test helpers cover the lot — render, sort, search, bulk-delete, inline-edit, named pagination — and run in under a second.
The Livewire livewire() helper gives you a fluent API for setting properties, calling methods, and asserting on the rendered output. Combine it with database factories and you get end-to-end coverage that exercises the real Eloquent query, the real filter logic, and the real Flux Blade output.
// tests/Feature/ListOrdersTest.php
use App\Livewire\ListOrders;
use App\Models\Customer;
use App\Models\Order;
use function Pest\Livewire\livewire;
it('renders paginated orders', function () {
Order::factory()->count(30)->create();
livewire(ListOrders::class)
->assertOk()
->assertSeeLivewire(ListOrders::class)
->assertViewHas('paginators');
});
it('filters by search term', function () {
$acme = Customer::factory()->create(['name' => 'Acme Co.']);
Order::factory()->for($acme)->create(['reference' => 'AC-001']);
Order::factory()->count(10)->create();
livewire(ListOrders::class)
->set('search', 'Acme')
->assertSee('Acme Co.')
->assertDontSee('AC-002');
});
it('flips sort direction on repeat clicks', function () {
Order::factory()->count(5)->create();
livewire(ListOrders::class)
->call('sort', 'amount')
->assertSet('sortField', 'amount')
->assertSet('sortDirection', 'asc')
->call('sort', 'amount')
->assertSet('sortDirection', 'desc');
});
it('bulk deletes selected orders', function () {
$orders = Order::factory()->count(5)->create();
$idsToDelete = $orders->take(3)->pluck('id')->map(fn ($id) => (string) $id)->toArray();
livewire(ListOrders::class)
->set('selected', $idsToDelete)
->call('bulkDelete')
->assertSet('selected', []);
expect(Order::count())->toBe(2);
});
it('resets pagination when search changes', function () {
Order::factory()->count(50)->create();
livewire(ListOrders::class)
->set('page', 2)
->set('search', 'something')
->assertSet('paginators.page', 1);
});
The bulk-delete test is the most important one in this list, because that's the action where a missing wire:key causes the silent-wrong-row bug — and the test would catch it immediately by asserting on the post-delete count. For deeper E2E coverage including JS-driven dropdowns and inline editing, layer in a Pest 4 browser test with Playwright for the three or four highest-value flows; keep the rest as fast Livewire-helper tests so the suite still finishes in seconds.
Ship it to production with confidence#
The combined component clocks in around 200 lines of PHP and 120 lines of Blade — smaller than the average data-table package's config file, and every line is yours to read. You've got server-side sorting, debounced search, multi-select filters, bulk-delete with confirmation and authorization, inline editing on the cell that matters, URL-state persistence on every filter, named paginators for the multi-table case, loading placeholders, and a Pest suite that proves the table behaves under every interaction.
From here, three directions are worth your next hour. First, if the data set is heading past ~100k rows, swap the like '%term%' search for a vector or full-text index using Laravel 13's whereVectorSimilarTo or a Meilisearch driver — the rest of the component stays identical. Second, if the table is the centerpiece of an admin panel and you want grouped resources, soft delete, audit logs, and policies wired in for free, consider whether the screen is really a Filament v4 dashboard rather than a hand-rolled Livewire one — both stacks are right for different sizes of admin. Third, harden the bulk operations: large bulk-delete or bulk-archive runs should dispatch a queued job rather than run in-request, and a clean queue strategy lives in the scaling Laravel queues production guide.
FAQ#
How do I add sorting to a Flux table in Livewire?
Mark each sortable column with the sortable, :sorted, and :direction props, point wire:click at a sort($field) method, and have that method either flip the direction on a repeat click or switch to the new column with an ascending default. The sort() method should also call $this->resetPage() so the user lands on page one of the new ordering instead of an empty page seven. The Eloquent builder then reads $sortField and $sortDirection and applies orderBy(...) before paginating.
How does pagination work in Flux UI tables?
Flux's <flux:table> accepts a :paginate prop that takes a Laravel paginator instance. Pair it with the WithPagination trait on your Livewire component and use a #[Computed] method that calls ->paginate(perPage: 25) on the Eloquent query. Flux renders the page-number controls and the "showing X to Y of Z" summary automatically; you don't need a separate <flux:pagination> unless you want pagination above and below the table.
What is the difference between Flux's table and the third-party Laravel datatable packages?
Flux's table is presentational — it's a thin Blade wrapper that renders columns, rows, and pagination controls but leaves filtering, sorting, and querying entirely to your Livewire component. Packages like livewire-tables and livewire-powergrid bundle the query layer, the filter DSL, the bulk-action contracts, and the column definitions into a configuration-driven system. Flux trades convenience for control: you write 60 lines of PHP instead of 20 lines of config, but you can read every one of them and the upgrade path is "upgrade Flux", not "learn a new DSL".
How do I add a bulk-delete action to a Flux table in Livewire 4?
Add a header checkbox with wire:model.live bound to a boolean, per-row checkboxes with wire:model.live="selected" and value="{{ $row->id }}", then a bulkDelete() method that authorizes the action, wraps the deletion in a transaction, and resets the selection array. Use wire:confirm on the button for a native confirm dialog or a flux:modal for a richer confirmation UI. Critically, make sure every row has a stable wire:key="model-{{ $row->id }}" — without it, bulk-edit can write to the wrong row after pagination.
How do I filter a Flux table with a search input?
Add a flux:input with wire:model.live.debounce.250ms="search" above the table and a $search property on the component. The Eloquent query in your #[Computed] method then applies a ->when($this->search, ...) clause that runs the like '%term%' (or full-text) match. The crucial piece is an updatedSearch() hook that calls $this->resetPage() — without it, the user types a query, the result shrinks, and they're stranded on an empty page from before the filter applied.
How do I keep two Flux tables on the same page paginating independently?
Pass a pageName: argument to each paginate() call — paginate(perPage: 10, pageName: 'orders') and paginate(perPage: 10, pageName: 'invoices'). Livewire writes the two pages to separate URL query parameters (?page=2&invoices-page=2), and the two <flux:table :paginate="..."> instances each control their own page state. When you need to call resetPage() for one of them, pass the page name as an argument: $this->resetPage(pageName: 'invoices').
How do I add inline editing to a row in a Flux table?
Track which cell is currently editable with a single string property ($editing = "orderId.field"), and render either the display value or a flux:input inside the cell based on whether that string matches. Wire the input with wire:keydown.enter="saveEdit", wire:keydown.escape="cancelEdit", and wire:blur="saveEdit" so the user can save with Enter, cancel with Escape, or commit on click-away. Keep it one cell at a time — multi-cell inline editing introduces validation and concurrency edge cases that most teams underestimate.
What's the difference between wire:model and wire:model.live in a Flux table?
wire:model updates the server-side property only when the user submits a form or another action fires — useful for inline-edit inputs where you don't want a round-trip on every keystroke. wire:model.live updates the property (and triggers a re-render) on every change, which is what you want for the search box and the multi-select filters. Add .debounce.250ms to live bindings on text inputs to avoid hammering the server while the user types.
Do I need Flux Pro for a data table like this?
No — the flux:table, flux:pagination, flux:input, flux:button, flux:dropdown, flux:menu, flux:checkbox, and flux:badge components used in this guide all ship in Flux Free. Flux Pro adds the Kanban table, the rich editor, the calendar, the date-picker, and themed-component variants, which you'd reach for in more specialized admin screens. For a paginated, sortable, filterable, bulk-action data table, Free is sufficient.