Skip Re-Rendering Livewire 4 Actions with #[Renderless]

The Livewire renderless attribute skips the render phase for actions that change nothing on screen. How #[Renderless], skipRender() and .renderless differ.

Steven Richardson
Steven Richardson
· 7 min read

I have a component that logs a page view when the reader scrolls to the bottom of an article. One database write, nothing on screen changes. Livewire still re-renders the entire Blade template, sends the resulting HTML back, and morphs it into the DOM. That work is pure waste, and the Livewire renderless attribute exists to delete it. The render phase has been opt-out for years — most people just never turn it off.

What the render phase actually costs#

Look at what Livewire does on every action. It hydrates the component from the incoming snapshot, applies property updates, calls your method, renders the Blade view to a string, and returns that HTML alongside a fresh snapshot. The browser then morphs the new HTML against the live DOM.

Here is the relevant slice of HandleComponents::update() in Livewire 4:

$this->callMethods($component, $calls, $context);

if ($html = $this->render($component)) {
    $context->addEffect('html', $html);
}

trigger('dehydrate', $component, $context);

$snapshot = $this->snapshot($component, $context);

Two separate things come back: the html effect and the snapshot. Skipping the render drops the first and keeps the second. For a view-counter action, the render is the whole bill — a Blade compile, whatever queries your template triggers, a few kilobytes of HTML over the wire, and a morph pass on the client.

It compounds on anything that fires repeatedly. Polling is the obvious case: if you are auto-refreshing a dashboard with wire:poll, every tick pays for a render whether the numbers moved or not.

Add the Livewire renderless attribute to silent actions#

#[Renderless] goes above the action method. That's the whole API:

<?php // resources/views/components/post/⚡show.blade.php

use Livewire\Attributes\Renderless;
use Livewire\Component;
use App\Models\Post;

new class extends Component {
    public Post $post;

    public function mount(Post $post)
    {
        $this->post = $post;
    }

    #[Renderless]
    public function incrementViewCount()
    {
        $this->post->incrementViewCount();
    }
};
<div>
    <h1>{{ $post->title }}</h1>
    <p>{{ $post->content }}</p>

    <div wire:intersect="incrementViewCount"></div>
</div>

wire:intersect fires the action when the sentinel scrolls into view — the same primitive behind infinite scroll pagination without a JS library. The write happens, the response carries no html key, and not a pixel of the page is touched.

The examples here use Livewire 4's single-file component syntax. Class-based components in app/Livewire behave identically — #[Renderless] is a method attribute either way. If the filename is new to you, that's covered in migrating from Livewire 3 to Livewire 4.

One thing worth being clear about: #[Renderless] runs as a call hook before your method body. It sets the skip flag, then your code executes normally. The action absolutely still runs.

Skip the render conditionally with skipRender()#

Attributes are all-or-nothing. When the decision depends on runtime state, call skipRender() from inside the action instead:

public function toggleBookmark()
{
    $bookmark = $this->post->bookmarks()->toggle(auth()->id());

    // The counter in the template only appears to admins,
    // so everyone else can skip the render entirely.
    if (! auth()->user()->isAdmin()) {
        $this->skipRender();
    }
}

Two things about skipRender() that the docs don't spell out.

It takes an optional argument. skipRender($html) swaps in a replacement string rather than suppressing the response body — that's the mechanism lazy loading uses internally to return a placeholder without rendering the real view. You rarely want this in application code, but it explains why the signature isn't skipRender(): void.

And it is one-directional per request. forceRender() sets a flag that makes every later skipRender() call a no-op:

public function skipRender($html = null)
{
    if (store($this)->has('forceRender')) {
        return;
    }

    store($this)->set('skipRender', $html ?: true);
}

Order matters. forceRender() has to run before the skip. Inside a method already decorated with #[Renderless] it's too late — the attribute set the flag before your first line executed.

Use .renderless for one-offs in the template#

The third form lives on the directive and never touches the class:

<button type="button" wire:click.renderless="trackDownload">
    Download
</button>

Livewire's JS sees the modifier, tags the action's metadata, and the server calls skipRender() after the method returns. Because it's a modifier rather than a method attribute, it also works on magic actions where there is no method to decorate:

<input wire:model.renderless.live="query">

I reach for .renderless when the same method is renderless from one call site and not from another. If every caller wants the same behaviour, put #[Renderless] on the method — it's discoverable from the class, and the template doesn't have to remember.

The Livewire renderless attribute does not mean "no request"#

This trips people up. A renderless action still makes the full round trip: HTTP request, snapshot validation, hydration, middleware, authorization, your method, dehydration, response. You save the Blade render and the DOM morph. Nothing else.

That also means public properties still sync. The snapshot is built after the render check, so $wire.someProperty in Alpine updates exactly as it always did — sharing state between Livewire and Alpine with $wire and @entangle keeps working through a renderless action. What stops updating is anything rendered by Blade.

If the request itself is the cost you want to remove, renderless is the wrong tool. Cache the expensive part with #[Computed(persist: true)], or move the work off the initial payload entirely.

Gotchas and Edge Cases#

Stale UI is silent. Nothing warns you. If the action mutates a property the template reads, the server state and the DOM simply drift apart until the next non-renderless action rerenders and everything jumps at once. This is the failure mode:

#[Renderless]
public function incrementViewCount()
{
    $this->post->incrementViewCount();

    $this->viewCount++; // Template reads {{ $viewCount }} — it will not update.
}

Validation errors vanish. Livewire catches ValidationException, moves the messages into the component's error bag, and stops propagation — displaying them is the render's job. Skip the render and the user gets a successful response with no feedback at all. Never put #[Renderless] on an action that validates.

Islands get skipped too. #[Renderless] sets a second flag alongside the skip:

function call()
{
    store($this->component)->set('skipIslandsRender', true);

    $this->component->skipRender();
}

SupportIslands checks skipIslandsRender and bails, so a renderless action cannot refresh an island either. If you were counting on partial rendering to give you the best of both — like a skeleton placeholder that swaps into an island — the attribute cancels it. The .renderless modifier suppresses that island's render for that call as well.

Loading states are fine. wire:loading is a client-side directive keyed on the commit lifecycle, not on the response HTML. wire:loading.attr="disabled" still adds and removes the attribute correctly across a renderless action. Same for wire:dirty.

Event listeners need the attribute, not the modifier. When a method runs as an #[On] listener, the normal call hook doesn't fire, so Livewire re-checks for #[Renderless] by hand in SupportEvents. The attribute works; there's no .renderless equivalent for a dispatched event.

Wrapping Up#

Audit the actions that fire on a timer, on scroll, or on every keystroke — analytics pings, view counters, autosaves, log writes. If the method touches nothing the template reads, #[Renderless] is a one-line change with no behavioural risk. Keep it off anything that validates or updates an island.

Then pin the behaviour down: Livewire::test()->call('incrementViewCount')->assertSee(...) will happily pass against a stale render, so assert on the data instead. Testing Livewire 4 components with Pest covers the assertions worth reaching for.

FAQ#

What does #[Renderless] do in Livewire?

#[Renderless] is a method attribute that skips the render phase of Livewire's lifecycle for that action. Livewire still hydrates the component, runs your method, and returns an updated snapshot — it just omits the html effect from the response, so no DOM morph happens. It's intended for actions with no visual side effects, like logging a view or firing an analytics event.

When should I use skipRender in Livewire?

Use $this->skipRender() when the decision to skip is conditional at runtime, rather than a fixed property of the method. A common case is an action that updates something only certain users can see: skip the render for everyone else. It's also the right choice when you don't want a method attribute for stylistic reasons, since it does exactly what #[Renderless] does.

Does Renderless stop the action from running?

No. The method body executes normally — database writes, event dispatches, jobs, all of it. #[Renderless] only suppresses the Blade render and the HTML that would have been sent back. The HTTP request, hydration, authorization checks and property synchronisation all happen exactly as they would without it.

How do I stop a Livewire component from re-rendering?

There are three ways, and they all resolve to the same internal flag. Add #[Renderless] above the action method, call $this->skipRender() inside the method body, or add the .renderless modifier to the directive in your template, as in wire:click.renderless="trackDownload". Choose the attribute when every call site wants the behaviour, and the modifier when only one does.

Why is my UI not updating after a renderless action?

Because that's exactly what renderless does — it prevents the template from re-rendering, so any property the action changed will still show its old value in the DOM. The server-side state is correct; the HTML is stale. Either remove the attribute, or move the state change into a separate action that renders normally. If the action also validates, the missing render is why your error messages never appear.

Steven Richardson
Steven Richardson

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