Show an Unsaved-Changes Indicator with Livewire's wire:dirty

Use Livewire's wire:dirty directive to flag unsaved form changes with zero JavaScript: messages, highlighted fields, the .remove modifier, and $dirty checks.

Steven Richardson
Steven Richardson
· 6 min read

A user spends five minutes filling in a form, clicks a link by mistake, and loses the lot. You want a visible "you have unsaved changes" cue, but wiring one up by hand with Alpine listeners is fiddly and drifts out of sync with the server. Livewire's wire:dirty directive does it for you with no JavaScript. It shows — or restyles — an element only while the component holds unsaved input, and clears the moment the client and server are back in sync. That matters even more once you've added the SPA-style navigation of wire:navigate, where there's no full page reload to jolt the user into noticing.

Show an unsaved-changes message with wire:dirty#

Add wire:dirty to an element and Livewire hides it by default, revealing it only when the client-side state diverges from the server. Drop your message next to the submit button:

<form wire:submit="update">
    <input type="text" wire:model="title">

    <button type="submit">Update</button>

    <div wire:dirty>Unsaved changes…</div>
</form>

With the default deferred wire:model, the property isn't sent to the server until the form submits. So the message appears on the first keystroke and stays put until the user saves — exactly the "unsaved work" window you want to flag. No listeners, no Alpine, no extra state.

If your form has more than a couple of fields, move the state into a Livewire form object first. wire:dirty behaves identically against form.title.

Toggle a class on dirty fields#

Sometimes a highlighted field reads better than a separate message. The .class modifier toggles a class directly on the input while it's dirty:

<input wire:model.live.blur="title" wire:dirty.class="border-yellow-500">

Here .live.blur syncs the property when the user tabs out of the field, so it reads dirty while they're editing and snaps clean on blur. The yellow border tracks that state without a second element on the page.

If you prefer a separate message but want it scoped to one property, point wire:target at the field:

<input wire:model.live.blur="title">
<div wire:dirty wire:target="title">Unsaved title…</div>

Without wire:target, that <div> reflects the entire component's dirty state, which is a common surprise. This pattern sits nicely alongside live form validation with Laravel Precognition: validate on blur, flag dirty on blur, all from the same interaction.

Invert the indicator with wire:dirty.remove#

The .remove modifier flips the logic — the element shows by default and hides the moment the component becomes dirty:

<div wire:dirty.remove>All changes saved</div>

I use this for a persistent status line that reads "All changes saved" until the user starts typing, then swaps to a warning. Put a plain wire:dirty warning and a wire:dirty.remove badge side by side and you get a status area that flips between the two states on its own.

Check any property with the $dirty expression#

Livewire 4 adds the $dirty expression, which lets you query dirty state inline instead of anchoring to a specific element. Use it in any wire: directive:

<div wire:show="$dirty">You have unsaved changes</div>

Narrow it to a single property, a nested path, or a list of names:

<div wire:show="$dirty('title')">Title has been modified</div>
<div wire:show="$dirty('user.name')">Name changed</div>
<div wire:show="$dirty(['title', 'body'])">Post edited</div>

In Alpine the same check is $wire.$dirty(), which is reactive — so a disabled-until-dirty save button enables itself the instant anything changes and greys back out after a save:

<button wire:click="save" x-bind:disabled="! $wire.$dirty()">Save</button>

When you need more than a boolean — say, driving a modal's open state off dirty input — reach for Alpine with wire:entangle.

Gotchas and Edge Cases#

wire:model.live (the immediate variant) quietly kills the effect. Every keystroke syncs to the server after the debounce, so the property flips clean almost immediately and your indicator flickers or never shows at all. Stick to the default deferred wire:model (dirty until submit) or .live.blur (dirty until the user tabs out).

A bare wire:dirty element always reflects the whole component. If you have three fields and want to flag only one, you need wire:target="field" — otherwise editing any field lights up every indicator.

Remember what "dirty" actually compares. It's the browser's current state versus the last payload Livewire sent, not your saved database row. It's a UX affordance, not an authorization or "has this really changed on disk" check — don't lean on it to decide what to persist.

Finally, the $dirty template expression is Livewire 4 only. On v3 you still have the wire:dirty directive and wire:target, but wire:show="$dirty" won't resolve.

Wrapping Up#

wire:dirty takes ten minutes to add and stops users losing work all day. Start with the deferred-model message, add wire:dirty.class where a highlighted field reads better, and reach for $dirty when you want one banner watching the whole form. From here, wire up per-step validation for longer forms, or tighten how state flows between components with reactive props.

FAQ#

What does wire:dirty do in Livewire?

wire:dirty toggles an element based on whether the component has unsaved input. By default the element is hidden and only appears while the client-side state differs from the last state synced to the server — typically the moment a user edits a field. As soon as a network request syncs the two, such as a form submit, the element hides again. It's Livewire's built-in way to signal "you have unsaved changes" with no JavaScript.

How do I show an unsaved-changes message in Livewire?

Add wire:dirty to any element inside your component and put your message in it, such as a <div wire:dirty>Unsaved changes…</div> next to the submit button. With the default deferred wire:model, the property isn't sent to the server until the form is submitted, so the message stays visible from the first keystroke until the user saves. You don't need any extra state or event listeners.

How do I highlight a dirty input field in Livewire?

Use the .class modifier: wire:dirty.class="border-yellow-500" on the input toggles that class on while the field is dirty and off once it syncs. Pair it with wire:model.live.blur so the field syncs when the user tabs away, giving you a highlight that appears during editing and clears on blur. This keeps the cue on the field itself rather than in a separate element.

What is the difference between wire:dirty and wire:dirty.remove?

Plain wire:dirty hides the element by default and shows it when the component becomes dirty. The .remove modifier inverts that: the element is visible by default and hidden as soon as something is dirty. Use plain wire:dirty for an "unsaved changes" warning, and .remove for an "all changes saved" badge that disappears the instant the user edits.

How do I check if any Livewire property is dirty?

Use the $dirty expression, which is new in Livewire 4. In a directive, wire:show="$dirty" reveals an element whenever any property has unsaved changes, and $dirty('title') narrows it to one property (nested paths and arrays of names work too). In Alpine you can call $wire.$dirty() for the same check, which is handy for disabling a save button until there's actually something to save.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.