Livewire Computed Properties and Caching Pitfalls

Bryan Heath Bryan Heath
· · 1 min read

Livewire computed properties look deceptively simple. You add #[Computed] to a method, access it like a property, and Livewire caches the result so the underlying query or calculation only runs once per request. Clean, efficient, elegant.

Until it's not. The caching behavior that makes computed properties fast is the same behavior that causes some of the most confusing bugs in Livewire applications. Stale data after an action, unexpected results across requests, computed properties that never seem to update — these are symptoms of misunderstanding how the cache works and when it clears.

Let's dig into the mechanics, walk through the common mistakes, and establish reliable patterns for cache invalidation.

How #[Computed] Works Under the Hood

A computed property is a method on your Livewire component decorated with the #[Computed] attribute. Livewire intercepts property access through __get() and routes it to the method. Here's the basic pattern:

<?php

namespace App\Livewire;

use App\Models\Order;
use Livewire\Attributes\Computed;
use Livewire\Component;

class Dashboard extends Component
{
    #[Computed]
    public function orders()
    {
        return Order::query()
            ->where('user_id', auth()->id())
            ->with('items')
            ->latest()
            ->get();
    }

    public function render()
    {
        return view('livewire.dashboard');
    }
}

In your Blade view, you access it with $this->orders or simply $orders:

<div>
    <h2>Your Orders ({{ $this->orders->count() }})</h2>

    @foreach ($this->orders as $order)
        <div class="border rounded p-4 mb-4">
            <p class="font-bold">Order #{{ $order->id }}</p>
            <p>{{ $order->items->count() }} items</p>
            <p>{{ $order->total_formatted }}</p>
        </div>
    @endforeach
</div>

Here's the critical behavior: this view references $this->orders three times (the count, the loop, and implicitly inside the loop). Without #[Computed], the query would run three times. With it, the result is cached on the first access and reused for the rest of the request. One query instead of three.

Request-Level Caching: The Default Behavior

By default, a computed property caches its result for the duration of a single Livewire request. This means:

The initial page load is one request. The computed property runs once and caches.

Each subsequent Livewire update (clicking a button, changing a property, submitting a form) is a new request. The cache is cleared, the computed property runs fresh, and the new result is cached for that request.

This is important to understand: the cache doesn't persist between Livewire updates by default. Every wire:click, every wire:model.live update, every dispatched event triggers a new request, and the computed property will re-execute.

#[Computed]
public function stats()
{
    // This runs once per Livewire request, not once per page load
    logger('Computing stats...');

    return [
        'total_orders' => Order::where('user_id', auth()->id())->count(),
        'total_spent' => Order::where('user_id', auth()->id())->sum('total'),
        'average_order' => Order::where('user_id', auth()->id())->avg('total'),
    ];
}

If your component has a button that triggers an action, and that action doesn't modify any data the computed property depends on, the computed property still re-runs on that request. The default cache scope is the request itself, not some dependency-tracking system.

Cross-Request Caching with persist

For expensive computations that you don't want to re-run on every single Livewire update, you can opt into cross-request caching by passing persist: true to the attribute:

use Livewire\Attributes\Computed;

#[Computed(persist: true)]
public function expensiveReport()
{
    return DB::table('transactions')
        ->selectRaw('DATE(created_at) as date, SUM(amount) as total')
        ->where('user_id', auth()->id())
        ->groupByRaw('DATE(created_at)')
        ->orderBy('date')
        ->get();
}

When persist is enabled, Livewire stores the computed result in the application cache (using your configured cache driver) and reuses it across subsequent Livewire requests for the same component instance. The cache key is tied to the component, so different component instances get different cached values.

This is powerful but dangerous. Once a value is persisted, it won't update when the underlying data changes. If a user places a new order, the expensiveReport computed property will continue returning the stale cached version until something explicitly invalidates it.

Cache Duration with seconds

You can control how long a persisted computed property stays cached using the seconds parameter:

#[Computed(persist: true, seconds: 300)]
public function leaderboard()
{
    return User::query()
        ->withCount('completedChallenges')
        ->orderByDesc('completed_challenges_count')
        ->limit(50)
        ->get();
}

This caches the leaderboard for 5 minutes. After the TTL expires, the next access will re-compute the value and cache it again. This pattern works well for data that changes periodically but doesn't need to be real-time — dashboard summaries, leaderboards, analytics counters, and similar read-heavy displays.

One common mistake here: developers set a seconds value without persist: true. The seconds parameter only applies to persisted caches. Without persist, the computed property uses request-level caching regardless of the seconds value.

Common Pitfall: Stale Data After an Action

This is the most frequently reported bug with computed properties. You have a list powered by a computed property and an action that modifies the data. After the action runs, the list doesn't update:

<?php

namespace App\Livewire;

use App\Models\Task;
use Livewire\Attributes\Computed;
use Livewire\Component;

class TaskList extends Component
{
    #[Computed]
    public function tasks()
    {
        return Task::where('user_id', auth()->id())
            ->orderBy('position')
            ->get();
    }

    public function complete(int $taskId): void
    {
        Task::where('id', $taskId)->update(['completed' => true]);
    }

    public function render()
    {
        return view('livewire.task-list');
    }
}

With the default request-level cache, this actually works fine. Here's why: when the user clicks the complete button, Livewire sends a new request. The complete() method runs, updating the database. Then render() runs, which accesses $this->tasks. Since this is a fresh request, the cache is empty, the query re-executes, and the view shows the updated data.

But add persist: true and the story changes:

// This will show stale data after complete() runs
#[Computed(persist: true)]
public function tasks()
{
    return Task::where('user_id', auth()->id())
        ->orderBy('position')
        ->get();
}

Now when complete() runs and the render accesses $this->tasks, Livewire finds the cached value from the previous request and returns it. The completed task still appears as incomplete in the UI.

Invalidating the Cache with unset()

The primary way to invalidate a computed property cache — whether request-level or persisted — is to unset() the property:

public function complete(int $taskId): void
{
    Task::where('id', $taskId)->update(['completed' => true]);

    unset($this->tasks);
}

Calling unset($this->tasks) clears the in-memory cache for the current request and removes the persisted cache entry if one exists. The next time $this->tasks is accessed (typically during render()), the method re-executes and the fresh result is cached again.

This is the pattern you should use any time an action modifies data that a computed property depends on. Make it a habit:

public function deleteTask(int $taskId): void
{
    Task::destroy($taskId);

    unset($this->tasks);
}

public function reorder(array $orderedIds): void
{
    foreach ($orderedIds as $position => $id) {
        Task::where('id', $id)->update(['position' => $position]);
    }

    unset($this->tasks);
}

Common Pitfall: Computed Properties That Depend on Component State

A subtler issue arises when a computed property reads from another component property that changes during the same request:

<?php

namespace App\Livewire;

use App\Models\Product;
use Livewire\Attributes\Computed;
use Livewire\Component;

class ProductCatalog extends Component
{
    public string $category = 'all';

    #[Computed]
    public function products()
    {
        return Product::query()
            ->when($this->category !== 'all', function ($query) {
                $query->where('category', $this->category);
            })
            ->get();
    }

    public function render()
    {
        return view('livewire.product-catalog');
    }
}

With request-level caching, this works as expected. When the user changes the category dropdown (which triggers a new Livewire request), the $category property is updated, and the computed property re-runs with the new category value because the cache is fresh for each request.

But if you access the computed property before the property changes within the same request, the cached result will be stale for the rest of that request:

public function switchCategory(string $newCategory): void
{
    // Bug: accessing products before changing category caches the old result
    $oldCount = $this->products->count();

    $this->category = $newCategory;

    // This still returns the cached result from the old category
    $newCount = $this->products->count();

    // $oldCount === $newCount, which is wrong
}

The fix: either restructure the code to avoid accessing the computed property before the state change, or unset() the computed property after modifying the dependency:

public function switchCategory(string $newCategory): void
{
    $oldCount = $this->products->count();

    $this->category = $newCategory;

    // Clear the cache so the next access re-computes
    unset($this->products);

    $newCount = $this->products->count(); // Now correct
}

Common Pitfall: Overusing persist

Developers sometimes add persist: true as a blanket optimization without considering the consequences. Here's a component where persistence causes a real problem:

class InboxComponent extends Component
{
    // Do NOT do this for frequently changing data
    #[Computed(persist: true)]
    public function unreadMessages()
    {
        return Message::where('recipient_id', auth()->id())
            ->whereNull('read_at')
            ->latest()
            ->get();
    }

    public function markAsRead(int $messageId): void
    {
        Message::where('id', $messageId)->update(['read_at' => now()]);

        // Even with unset, persist adds unnecessary overhead here
        unset($this->unreadMessages);
    }
}

This is counterproductive for two reasons. First, the data changes frequently (every time the user reads a message), so you're constantly invalidating the cache and re-computing anyway. Second, persisted caches serialize data to the cache store and deserialize it on read, which adds overhead. For a query that's fast and changes often, the caching overhead exceeds the savings.

Rule of thumb: use persist for data that is expensive to compute and infrequently changed. Dashboard statistics, configuration lookups, report aggregations — these are good candidates. Interactive lists, user notifications, form-dependent data — these are bad candidates.

Common Pitfall: Returning Query Builders Instead of Results

A computed property that returns a query builder instead of an executed result won't cache the way you expect:

// Bug: this caches the Builder object, not the results
#[Computed]
public function tasks()
{
    return Task::where('user_id', auth()->id())
        ->orderBy('position');
    // Missing ->get()
}

When you iterate over $this->tasks in your view, Laravel will execute the query each time because the cached value is the builder object, not the collection. The caching mechanism stores whatever the method returns, and a Builder object executes a new query each time it's iterated. Always call ->get(), ->paginate(), or another terminal method to ensure you cache the result, not the query.

Pattern: Computed Properties with Pagination

Computed properties pair well with WithPagination, but there's a subtlety. Livewire updates the page number as a property on the component, and each page change is a new request. Since default computed properties re-run on every request, pagination works naturally:

<?php

namespace App\Livewire;

use App\Models\Article;
use Livewire\Attributes\Computed;
use Livewire\Component;
use Livewire\WithPagination;

class ArticleIndex extends Component
{
    use WithPagination;

    public string $sortBy = 'latest';

    #[Computed]
    public function articles()
    {
        return Article::query()
            ->when($this->sortBy === 'latest', fn ($q) => $q->latest())
            ->when($this->sortBy === 'popular', fn ($q) => $q->orderByDesc('views'))
            ->when($this->sortBy === 'title', fn ($q) => $q->orderBy('title'))
            ->paginate(20);
    }

    public function updatedSortBy(): void
    {
        $this->resetPage();
    }

    public function render()
    {
        return view('livewire.article-index');
    }
}

Do not use persist: true with paginated computed properties. The page number changes on every page navigation, but the persisted cache would return the results from the previous page. You'd need to unset the cache on every page change, defeating the purpose of persistence entirely.

Pattern: Multiple Computed Properties That Share a Query

Sometimes you need to derive multiple values from the same dataset. Use one computed property for the data and others for the derived values:

#[Computed]
public function orders()
{
    return Order::where('user_id', auth()->id())
        ->where('created_at', '>=', now()->startOfMonth())
        ->with('items')
        ->get();
}

#[Computed]
public function totalRevenue()
{
    return $this->orders->sum('total');
}

#[Computed]
public function averageOrderValue()
{
    $orders = $this->orders;

    return $orders->isEmpty() ? 0 : $orders->avg('total');
}

#[Computed]
public function topSellingItems()
{
    return $this->orders
        ->flatMap->items
        ->groupBy('product_id')
        ->map->sum('quantity')
        ->sortDesc()
        ->take(5);
}

This works because $this->orders is cached after the first access. The totalRevenue, averageOrderValue, and topSellingItems computed properties all read from the cached collection without triggering additional queries. Each derived computed property is also individually cached, so accessing $this->totalRevenue multiple times in the view only computes once.

When you need to invalidate, unset() the base computed property and all derived ones:

public function refundOrder(int $orderId): void
{
    Order::where('id', $orderId)->update(['status' => 'refunded']);

    unset($this->orders);
    unset($this->totalRevenue);
    unset($this->averageOrderValue);
    unset($this->topSellingItems);
}

If you find yourself unsetting many computed properties after every action, that's a signal to consolidate. Consider having a single clearCache() method that unsets all of them in one place:

private function clearOrderCache(): void
{
    unset($this->orders);
    unset($this->totalRevenue);
    unset($this->averageOrderValue);
    unset($this->topSellingItems);
}

public function refundOrder(int $orderId): void
{
    Order::where('id', $orderId)->update(['status' => 'refunded']);

    $this->clearOrderCache();
}

Pattern: Cache Invalidation from External Events

What happens when data changes outside your component? Another component, a background job, or a webhook updates the database, and your computed property needs to reflect that change. Use Livewire event listeners to trigger cache invalidation:

<?php

namespace App\Livewire;

use App\Models\Notification;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;

class NotificationBell extends Component
{
    #[Computed(persist: true, seconds: 60)]
    public function unreadCount()
    {
        return Notification::where('user_id', auth()->id())
            ->whereNull('read_at')
            ->count();
    }

    #[On('notification-received')]
    public function handleNewNotification(): void
    {
        unset($this->unreadCount);
    }

    #[On('notifications-cleared')]
    public function handleCleared(): void
    {
        unset($this->unreadCount);
    }

    public function render()
    {
        return view('livewire.notification-bell');
    }
}

Other components can dispatch these events to trigger a refresh:

// In another component
public function markAllRead(): void
{
    Notification::where('user_id', auth()->id())
        ->whereNull('read_at')
        ->update(['read_at' => now()]);

    $this->dispatch('notifications-cleared');
}

This pattern keeps components loosely coupled. The notification bell doesn't need to know about every component that might affect the unread count — it just listens for events and invalidates its cache when one arrives.

Debugging Computed Property Issues

When a computed property isn't behaving as expected, add temporary logging to understand when it executes and what it returns:

#[Computed]
public function tasks()
{
    logger('tasks() computed property executing', [
        'category' => $this->category,
        'request_id' => request()->id(),
    ]);

    $result = Task::where('category', $this->category)->get();

    logger('tasks() returning ' . $result->count() . ' results');

    return $result;
}

Check your log output. If you see the message once per request, caching is working. If you see it multiple times in a single request, something is calling unset() mid-request and then re-accessing the property. If you never see the message on a subsequent request, the persisted cache is not being invalidated when it should be.

Conclusion

Livewire computed properties are one of those features that reward a precise understanding. The defaults are sensible — request-level caching prevents duplicate queries within a single render cycle. The problems start when you add persistence without understanding the invalidation requirements, or when you access computed properties at the wrong point in the request lifecycle.

The rules to internalize: default computed properties cache per-request and re-run on every Livewire update, which is correct for most use cases. Use persist: true only for expensive, infrequently-changing data. Always unset() computed properties after actions that modify their underlying data. Never use persist with paginated data. Return executed results from computed methods, not query builders. And when multiple computed properties derive from the same data, build a chain where derived properties read from a single cached base.

Computed properties aren't magic — they're a caching layer with clear rules. I spent a frustrating afternoon debugging stale data before I really internalized the persist behavior, and I haven't been bitten since. Once you understand the rules, they become one of the most reliable tools in your Livewire toolkit.

Share:

Related Posts