The bug report is always the same shape. "I deleted a line and then the wrong one was selected." It is not reproducible on a fresh load, it never throws, nothing lands in the logs, and it gets closed as could not reproduce until it comes back a week later. The cause is one missing attribute, and most of us have read wire:key in the docs as a performance hint and moved on.
Reproduce the stale row on purpose#
Build the smallest list that can go wrong: rows of invoices, each with an Alpine-controlled actions menu and an uncontrolled text input, plus a delete action on the server. No wire:key anywhere. This component is broken in a way that passes every test you are likely to write for it.
<?php // resources/views/components/⚡invoice-list.blade.php
use App\Models\Invoice;
use Livewire\Attributes\Computed;
use Livewire\Component;
new class extends Component {
#[Computed]
public function invoices()
{
return Invoice::orderBy('due_at')->get();
}
public function delete(int $invoiceId): void
{
Invoice::whereKey($invoiceId)->delete();
unset($this->invoices); // Bust the computed property cache
}
};
<div>
@foreach ($this->invoices as $invoice)
<div class="flex items-center gap-4" x-data="{ open: false }">
<span>{{ $invoice->reference }}</span>
{{-- Uncontrolled: its value lives only in the DOM node --}}
<input type="text" placeholder="Add a note">
<button x-on:click="open = ! open">Actions</button>
<div x-show="open">
<button wire:click="delete({{ $invoice->id }})">Delete</button>
</div>
</div>
@endforeach
</div>
Now drive it by hand. Type check with finance into the note field on the third row, open the actions menu on the third row, then delete the second row. The list redraws with two rows — and the note text and the open menu are now sitting on the row that used to be third and is now second. The data is right. Everything hanging off the DOM node is wrong.
Read the morph that caused it#
Livewire does not replace a component's HTML on every update. It morphs: it walks the old tree and the new tree simultaneously, compares each element it meets, and makes surgical changes only where they differ. That is what preserves focus, event listeners and input values between requests, and it is exactly what hurts you here.
With three rows and no keys, the comparison is purely positional:
Old DOM New HTML What Livewire does
───────────────────────────────────────────────────────────────────────────
row 1 (INV-1) → row 1 (INV-1) same, leave alone
row 2 (INV-2, empty) → row 2 (INV-3, empty) change the text in place
row 3 (INV-3, note) → — remove the last element
Livewire never moved row three. It rewrote row two's text content to say INV-3 and then deleted the trailing element — which happened to be the node holding your note and your open menu. Livewire does have a look-ahead step that catches many of these cases, and it injects <!--[if BLOCK]><![endif]--> markers around Blade conditionals so it can tell an insertion from a change, but neither helps when a list has reordered or shrunk and nothing identifies the rows.
Four things ride on the DOM node and therefore break, each with its own bug report:
- Alpine component state —
x-datais initialised per element, so an open dropdown or an expanded panel moves to whichever record inherits the node. This is the same lifecycle that makes Alpine andwire:entanglemodal state sensitive to re-renders. - Focus — the caret jumps to the neighbouring row mid-typing.
- Scroll position inside a scrollable cell resets or lands on the wrong record.
- Uncontrolled input values — anything without
wire:modelis DOM-only state, so it stays on the node and gets a new owner.
If your bug report matches one of those four, stop reading the server code. It is a keying problem.
Key the loop with the model's primary key#
Give each row a stable identity and the positional walk stops being a guess. Add wire:key to the opening tag of the first element inside the loop, built from the model's primary key.
@foreach ($this->invoices as $invoice)
<div wire:key="invoice-row-{{ $invoice->id }}" class="flex items-center gap-4" x-data="{ open: false }">
{{-- ... --}}
</div>
@endforeach
Delete row two now and Livewire matches invoice-row-3 to invoice-row-3, moves the node, and the note and the open menu travel with the record they belong to.
Two things to get right. Never key on $loop->index — that is the position you were trying to stop Livewire relying on, so it changes the moment the list shrinks or reorders and buys you nothing. The morphing docs use $loop->index in their toy example and it is a bad habit to copy. And never key on a value the user can edit: a key built from a name or an email changes on save, so Livewire tears the row down and rebuilds it, which reads as a flash and silently discards that row's client state.
Prefix every key so it stays unique page-wide#
Keys are compared across the whole rendered component tree, not within the loop that wrote them. Render invoices and customers on the same page, key both with a bare {{ $model->id }}, and the moment an invoice and a customer share an id you have a duplicate key — which is worse than no key, because Livewire now actively matches two unrelated elements.
<div>
@foreach ($this->invoices as $invoice)
<div wire:key="invoice-row-{{ $invoice->id }}">...</div>
@endforeach
@foreach ($this->customers as $customer)
<div wire:key="customer-row-{{ $customer->id }}">...</div>
@endforeach
</div>
Adopt one convention and apply it everywhere: {noun}-{context}-{id}. It costs nothing, it makes duplicates obvious in review, and it makes them findable in the element inspector — run $$('[wire\\:key]').length against new Set of the same values in the console and any gap is a collision.
Key nested components to stop stale server state#
This is the severe case, because it is no longer only the DOM that goes wrong. Each nested Livewire component carries its own wire:id and wire:snapshot, and when the parent re-renders it emits a placeholder for each child rather than the child's full markup. If the children have no keys, Livewire can match a surviving child element to the wrong record's placeholder — and the component instance it keeps holds the previous record's public properties.
@foreach ($this->invoices as $invoice)
<div wire:key="invoice-row-{{ $invoice->id }}">
<livewire:invoice-status :$invoice :wire:key="'invoice-status-'.$invoice->id" />
</div>
@endforeach
Note the :wire:key binding syntax on the component tag, and note that the child needs its own key even though its wrapper already has one. A Livewire component nested deep inside a keyed @foreach is still an independent component that Livewire has to match up between requests. The two errors that show up when it cannot are worth memorising, because they are the only time this failure mode announces itself at all:
Error: Component already initialized
Error: Snapshot missing on Livewire component with id: ...
While you are in here, check that the child is actually being fed its data rather than caching it from mount — keys fix identity, but a parent changing a value a child already holds is a separate problem solved by reactive props between parent and child components.
Wrap conditionals and bypass the morph with wire:replace#
Loops are not the only place the morph has to guess. A conditional that renders structurally different trees in each branch presents the same ambiguity: is this a changed element or an inserted one? Livewire's injected morph markers handle most of it, but they are produced by parsing templates with regex and can miss. The reliable fix is to wrap the conditional in an element that is always present:
<div> {{-- Always rendered, so the morph has a stable anchor --}}
@if ($errors->has('reference'))
<div>{{ $errors->first('reference') }}</div>
@endif
</div>
If markers are causing more trouble than they solve in your app, they can be turned off wholesale with 'inject_morph_markers' => false in config/livewire.php — but treat that as a last resort, not a tuning knob.
For a subtree that should never be morphed at all, wire:replace tells Livewire to throw away the children and insert the server's new elements instead. That is the right call for third-party widgets and custom elements that own their internal lifecycle, which is the same boundary problem covered in integrating third-party JavaScript with Livewire.
<div wire:replace>
<json-viewer>@json($payload)</json-viewer>
</div>
{{-- .self replaces the element itself too, resetting its x-data --}}
<div x-data="{ open: false }" wire:replace.self>
...
</div>
Key lazy children and islands#
Deferred rendering makes the ordering problem worse, not better, because the list changes shape twice: once when the placeholder renders and again when the real content arrives. A lazy child in a loop needs a key for the same reason an eager one does — Livewire has to know which placeholder belongs to which record before it can swap anything in.
@foreach ($this->invoices as $invoice)
<livewire:invoice-breakdown
:$invoice
:wire:key="'invoice-breakdown-'.$invoice->id"
lazy
/>
@endforeach
The same applies when you are slicing a page into islands and lazily loaded components: an island updates independently, so its identity inside a list has to be stable or the update lands on the wrong one. If you are rendering skeleton placeholders while islands load, key the placeholder and the loaded content identically, or the swap is a destroy-and-recreate rather than a morph.
Avoid the four keying mistakes that create new bugs#
Adding keys is cheap, which is why it is easy to add them badly. These are the four I pull up most often in review.
Keying everything. A @foreach of plain <td>s with no Alpine, no listeners and no uncontrolled inputs does not need a key, and keying it hides the places that do. Key loops that render a component, an x-data block, or any input the server does not control.
Keying on mutable values. A key built from a slug, a name or an email changes when the user edits it, forcing a full teardown of the row. Key on the id.
Duplicate keys. Two loops in one component tree, both keyed by bare id, collide silently. Livewire will not always warn you — prefix.
Reordering without keys. Drag-and-drop is the worst case, because every element is wrong at once rather than one of them: see drag-and-drop reordering with wire:sort, which is unusable without stable keys.
Catch missing keys in review and in Pest#
Make the rule mechanical so it does not depend on anyone remembering: every @foreach that renders a Livewire component, an Alpine block, or an uncontrolled input carries a prefixed wire:key. That is a one-line grep in CI and a one-line comment in review.
The test matters more than the rule, and the obvious test is the one that does not work. Asserting on the initial render passes on the broken component, because the first render is always correct — the bug only exists in the morph. Assert on the state after the mutation instead.
<?php
use App\Models\Invoice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('keeps a stable key on the surviving rows after a delete', function () {
$invoices = Invoice::factory()->count(3)->create();
Livewire::test('invoice-list')
->call('delete', $invoices[1]->id)
// The survivors still carry their own identity, not their new position
->assertSeeHtml('wire:key="invoice-row-'.$invoices[2]->id.'"')
->assertDontSeeHtml('wire:key="invoice-row-'.$invoices[1]->id.'"');
});
Be honest about what that proves: it asserts the keys are emitted and correct, which is the thing you can regress in a template. It does not prove the browser preserved the Alpine state, because no server-side assertion can — that needs a browser test. In practice the emitted-key assertion catches the regression that actually happens, which is someone deleting the attribute during a refactor. If you want the full picture of what Livewire::test() can and cannot reach, testing Livewire components with Pest covers the boundary.
Wrapping Up#
Go through every @foreach in your app that renders a component or an Alpine block and give it a prefixed key built from the model's primary key. It takes an afternoon and it closes a whole category of bug reports that currently get marked could not reproduce.
Two places to look next: reactive props between parent and child components, because once identity is stable the next question is how data flows down to the child, and Livewire 4 JavaScript interceptors if you want to watch the request lifecycle around a morph while you are debugging one.
FAQ#
What does wire:key do in Livewire?
wire:key gives an element a stable identity that survives re-renders. Without it, Livewire's morphing algorithm matches old and new elements by their position in the tree, so a list that shrinks or reorders gets matched up wrongly. With a key, Livewire matches elements by that value instead and moves the real node rather than rewriting a neighbour's contents.
Why is my Livewire component showing the wrong data after deleting a row?
Almost certainly a missing wire:key on the loop. When you delete a row, the server sends back a shorter list, and Livewire walks both trees positionally — so it rewrites the contents of the removed row's neighbour and deletes the trailing element. Any Alpine state, focus or uncontrolled input value attached to those nodes ends up on the wrong record. Add wire:key="invoice-row-{{ $invoice->id }}" to the first element inside the loop.
Should I use the loop index for wire:key?
No. $loop->index is the element's position, which is exactly the thing you are trying to stop Livewire using — it changes whenever the list shrinks or reorders, so the key provides no stable identity at all. Use the model's primary key with a prefix that names the loop. Avoid any value the user can edit, such as a name or a slug, because the key changing forces a full rebuild of that row.
Does wire:key need to be unique across the whole page?
Yes. Keys are compared across the whole rendered component tree, not scoped to the loop that produced them. If you render invoices and customers on the same page and key both with a bare model id, the ids will eventually collide and Livewire will match two unrelated elements to each other. Prefix every key, for example invoice-row-7 and customer-row-7.
Why does my Alpine dropdown open on the wrong row in Livewire?
Alpine state is initialised on the DOM element, so it belongs to the node rather than the record. When an unkeyed list changes shape, Livewire reuses that node for a different record and the open state travels with it. Keying the loop fixes it. If you need the state reset on every render instead, wire:replace.self on the x-data element will force it.
Do I need wire:key on nested Livewire components?
Yes, and you need it even when the wrapping element in the loop already has one. Each nested component is independent, with its own id and snapshot, and Livewire has to match the surviving child elements to the right records between requests. Without a key it can keep a child instance for the wrong record, which means the child's server-side public properties are stale too, not just its DOM. You will sometimes see "Component already initialized" or "Snapshot missing on Livewire component with id" when this happens.