A settings page with a "Show advanced options" checkbox. wire:click="toggleAdvanced" flips a boolean, @if($advanced) renders the panel, and it works — until you open the network tab and watch a full POST, re-render and DOM morph fire every time someone ticks a box that changes nothing on the server. Livewire's wire:show and wire:text exist to delete that request entirely, and the docs introduce them in about a sentence each.
The round trip a checkbox did not need#
Here is the version almost everyone writes first.
<?php // resources/views/components/⚡settings.blade.php
use Livewire\Component;
new class extends Component {
public bool $advanced = false;
public string $webhookUrl = '';
};
<div>
<label>
<input type="checkbox" wire:model.live="advanced"> Show advanced options
</label>
@if ($advanced)
<input type="url" wire:model="webhookUrl" placeholder="Webhook URL">
@endif
</div>
Every tick of that checkbox is a network request. The server hydrates the component, re-runs the render, ships fresh HTML, and Livewire morphs it into the page. On a fast connection you will not notice. On a 200ms mobile connection the panel visibly lags behind the checkbox, and the input inside it is destroyed and recreated — so anything the user had typed and not yet committed is gone.
The same thing with wire:show:
<div>
<label>
<input type="checkbox" wire:model="advanced"> Show advanced options
</label>
<div wire:show="advanced">
<input type="url" wire:model="webhookUrl" placeholder="Webhook URL">
</div>
</div>
Two changes. wire:model.live became plain wire:model, so the checkbox no longer commits to the server on change. @if became wire:show, so the panel is rendered once and toggled with CSS. The network tab stays empty until the user submits. The half-typed webhook URL survives, because the input was never removed from the DOM.
The usual escape hatch here was Alpine — x-data, x-show, and then @entangle to glue the Alpine state back to the Livewire property. That still works, and there are cases where sharing state between Livewire and Alpine with $wire and @entangle is the right call. For a toggle that is already a Livewire property, it is a detour you no longer need.
What wire:show actually evaluates#
wire:show takes a JavaScript expression, evaluated against the component's client-side state, and sets display: none when it is falsy. That client-side state is the $wire object Livewire already keeps in the browser — the same one Alpine reads.
<div wire:show="advanced">...</div>
<div wire:show="quantity > 3">Bulk discount applies</div>
<div wire:show="! errors.length">Everything looks good</div>
Because the expression reads $wire, it re-evaluates whenever that state changes — whether the change came from a client-side assignment, a wire:model input, or a server round trip triggered by something else entirely. You do not need to re-render to keep it in sync.
The corollary is the mistake everyone makes in the first ten minutes: it is not PHP.
{{-- Wrong: $advanced is not a JavaScript variable --}}
<div wire:show="$advanced">...</div>
{{-- Wrong: count() is a PHP function --}}
<div wire:show="count(items) > 0">...</div>
{{-- Right --}}
<div wire:show="advanced">...</div>
<div wire:show="items.length > 0">...</div>
Nothing errors loudly. The element just stays hidden, or stays visible, and you lose ten minutes to it.
wire:text, or a Blade echo that keeps up with the user#
wire:text is the read half of the same idea. It binds an element's text content to a client-side expression, so it updates without a round trip. If you know Alpine's x-text, it is the same directive with $wire already in scope.
The classic case is a character counter. The version that costs one request per keystroke:
<textarea wire:model.live="bio"></textarea>
<span>{{ strlen($bio) }} / 280</span>
Even with the default 150ms debounce, that is a request every time the user pauses. The version that costs nothing:
<textarea wire:model="bio"></textarea>
<span wire:text="bio.length"></span> / 280
Plain wire:model still updates the client-side property immediately — it just defers the network commit until an action fires. So wire:text sees the new value straight away and the counter tracks the keystrokes, while the server only learns about $bio on submit. That is the pattern that replaces most .live usage: bind with wire:model, render the derived display client-side, round-trip once. Reach for a properly debounced wire:model.live only when the server genuinely needs each intermediate value — a live search against the database, say, not a character count.
wire:text is also how you build an optimistic UI. Update the value in JavaScript for the instant feedback, and let the action persist it in the background:
<button x-on:click="$wire.likes++" wire:click="like">❤️ Like</button>
Likes: <span wire:text="likes"></span>
The count moves the moment the button is pressed. The like() method writes to the database and re-syncs the real number on the response. If you want the action to persist without re-rendering the component at all, pair it with #[Renderless].
@if vs wire:show vs island: the decision table#
This is the part worth bookmarking.
| Situation | Use |
|---|---|
| Pure UI toggle, both branches cheap and already rendered | wire:show |
| Which data you query changes with the branch | @if |
| Content is authorisation-gated or belongs to another user | @if / @can |
| Hidden branch is expensive to render | Island |
| Branch holds a form the user is mid-way through | wire:show |
| Content must be in the initial HTML for SEO | @if (rendered true) |
| Toggle fires dozens of times per session | wire:show |
The heuristic underneath it: cheap to render, expensive to fetch over the wire → wire:show. Expensive to render → island. wire:show does not save you any server work. The markup is still rendered on every parent update, hidden or not. If that branch runs three aggregate queries to build a chart nobody has opened, hiding it with CSS has saved you exactly nothing.
The one thing wire:show must never hide#
wire:show renders the markup and hides it with CSS. View source reveals every byte of it.
{{-- Wrong. The admin panel is in the HTML for every user. --}}
<div wire:show="isAdmin">
<button wire:click="deleteAllOrders">Delete all orders</button>
Revenue this month: {{ $revenue }}
</div>
A non-admin gets display: none and a full copy of the markup, the revenue figure, and the name of an action they can now try to call. Removing display: none in DevTools takes about two seconds.
{{-- Right. The server decides, and the markup never ships. --}}
@can('manage-orders')
<div>
<button wire:click="deleteAllOrders">Delete all orders</button>
Revenue this month: {{ $revenue }}
</div>
@endcan
And because a client-side boolean is client-side, isAdmin is editable from the browser like any other public property — which is what #[Locked] exists for. If a property drives an authorisation decision anywhere, lock it against client-side tampering and re-check the policy in the action. Hiding the button was never the security control.
The rule is short: if the answer to "may this user see this?" is anything other than "yes, always", it is a server decision.
Composing wire:show with transitions and wire:model#
A CSS toggle with no animation looks abrupt, and the directive you want here is Alpine's x-transition, not wire:transition:
<div wire:show="open" x-transition.duration.300ms>
<p>Accordion content.</p>
</div>
This trips people up because the names look interchangeable. They are not. wire:transition hands a DOM swap to the browser's View Transitions API, and it fires when Livewire adds, removes or changes an element during an update — exactly the @if case. wire:show never removes the element, so there is no swap for wire:transition to hook. If you want the native View Transitions path, you want wire:transition on a server-rendered branch; for a client-side toggle you want x-transition, which animates classes on a JavaScript timeline and works on display changes.
Hidden is not unmounted#
A hidden subtree is still fully alive, and this produces two real bugs.
Form inputs still submit. A wire:model binding inside a hidden block is still bound. The property still has a value, and that value still goes to the server on the next request:
<div wire:show="reason === 'other'">
<input wire:model="otherReason">
</div>
Switch the reason away from "other" and $otherReason keeps whatever was typed. Your cancel() action receives a stale free-text reason for a cancellation that is no longer "other". Clear it when the branch closes:
public function updatedReason(string $value): void
{
// Stop a stale value surviving the branch that collected it.
if ($value !== 'other') {
$this->reset('otherReason');
}
}
Polling keeps polling. wire:poll inside a hidden wire:show block fires on schedule, because polling is not tied to the CSS display value. The fix is the .visible modifier, which only polls while the element is actually visible on the page:
<div wire:show="showMetrics">
<div wire:poll.visible.10s="refreshMetrics">
{{ $activeUsers }} active
</div>
</div>
Worth knowing if you are auto-refreshing a dashboard with wire:poll and half the panels start hidden. When the branch is genuinely expensive — not just visually absent — stop hiding it and move it into an island that lazy-loads on demand.
Gotchas and Edge Cases#
Inline display loses to !important. wire:show writes style="display: none" on the element. A utility class with !important on display — or a framework rule you did not write — wins, and the element stays stubbornly visible. Check the computed styles before blaming the directive.
Flash of visible content on load. Markup that starts hidden is visible for the frame before Livewire boots and applies the style. Use wire:cloak on the element, or a hidden class you remove, the same way you would use x-cloak in Alpine.
wire:text overwrites children. It replaces the element's entire text content, so anything you nest inside it is deleted on first evaluation. Put it on its own empty element.
{{-- The icon disappears the moment Livewire boots --}}
<span wire:text="status"><svg>...</svg></span>
{{-- Keep them as siblings --}}
<span><svg>...</svg> <span wire:text="status"></span></span>
Keep wire:text bound to scalars. {{ }} runs PHP's string conversion on the server, so a Carbon instance formats itself and null becomes an empty string. wire:text does neither — it renders the dehydrated JSON value through JavaScript. A date property arrives as an ISO string, an object arrives as [object Object], and null renders as the word null in some cases rather than nothing. Format it into a plain string property on the server, then bind to that.
Both directives require a mounted component. Neither works outside a Livewire component's root element, which is the usual cause of a wire:show that never fires in a partial you extracted.
Wrapping Up#
Reach for wire:show and wire:text whenever a branch or a value is purely presentational and both sides are already rendered — toggles, accordions, counters, optimistic increments. Keep @if for anything that changes what you query, anything expensive to render, and every single authorisation decision. When you find yourself hiding something the server spent three queries building, that is the signal to promote it to a lazy-loaded island instead. Then pin the behaviour down with component tests in Pest, because a directive that stops making requests is a directive whose regression nobody notices until the bill arrives.
FAQ#
What is the difference between wire:show and @if in Livewire?
@if is evaluated on the server, so the hidden branch is never sent to the browser and changing the condition requires a round trip that re-renders the component and morphs the DOM. wire:show is evaluated in the browser against Livewire's client-side state and toggles the CSS display property, so the markup is always present and the toggle costs nothing. Use @if when the branch changes what you query or who is allowed to see it, and wire:show when both branches are cheap and already rendered.
Does wire:show make a network request?
No. wire:show evaluates a JavaScript expression against the component's client-side state and sets display: none directly in the browser. No request is sent and the component is not re-rendered. The element is only re-evaluated when the underlying state changes, which can happen client-side or as a side effect of some other round trip.
Is wire:show safe for hiding sensitive data?
No. The markup inside a wire:show block is rendered on the server and sent to every user, then hidden with CSS — it is fully visible in view source and one DevTools edit away from being displayed. Anything gated by a policy, an entitlement or another user's ownership must be wrapped in @if or @can so the server decides and the markup never ships. Treat wire:show as a presentation tool only.
How do I update text in Livewire without a server round trip?
Use wire:text with an expression over the component's client-side state, for example <span wire:text="bio.length"></span> alongside a <textarea wire:model="bio">. Plain wire:model updates the browser-side property immediately and only defers the network commit, so the bound text keeps pace with typing while the server is left alone until an action fires. This replaces most uses of wire:model.live for values that are only ever displayed.
Can I animate wire:show with wire:transition?
No — use Alpine's x-transition instead, as in <div wire:show="open" x-transition.duration.300ms>. wire:transition hooks the native View Transitions API when Livewire adds, removes or changes an element during a server update, and wire:show never removes the element, so there is no swap for it to animate. Alpine's transitions apply classes across a JavaScript timeline and work correctly on a display toggle.
Do wire:poll and Alpine keep running inside a hidden wire:show block?
Yes. Hiding an element with display: none does not unmount it, so wire:poll keeps firing on its interval, Alpine keeps its x-data state, and any wire:model inputs inside it remain bound and still send their values to the server. Add the .visible modifier to wire:poll to pause polling while the element is off-screen, reset stale properties when a branch closes, and move the subtree into a nested component or island if you need it genuinely gone.