Documentation

Livewire Best Practices

Livewire Best Practices

Overview

This guide covers best practices, patterns, and tips for building production-ready Livewire applications with UltraViolet Pro.

Component Organization

1. Keep Components Focused

Bad - Too much responsibility:

class Dashboard extends Component
{
    public function users() { }
    public function orders() { }
    public function stats() { }
    public function reports() { }
    // 20 more methods...
}

Good - Single responsibility:

class UsersList extends Component { }
class OrdersTable extends Component { }
class StatsWidget extends Component { }
class ReportsChart extends Component { }

2. Use Subdirectories

Organize components by feature:

app/Livewire/
├── Dashboard/
│   ├── StatsWidget.php
│   ├── RecentOrders.php
│   └── QuickActions.php
├── Users/
│   ├── UsersList.php
│   ├── UserForm.php
│   └── UserProfile.php
└── Reports/
    └── SalesReport.php

State Management

1. Only Store What You Need

Bad - Storing entire eloquent models:

class UserEdit extends Component
{
    public User $user; // Entire model in state!

    public function mount(User $user)
    {
        $this->user = $user;
    }
}

Good - Store only necessary data:

class UserEdit extends Component
{
    public $userId;
    public $name;
    public $email;

    public function mount(User $user)
    {
        $this->userId = $user->id;
        $this->name = $user->name;
        $this->email = $user->email;
    }
}

2. Use Computed Properties

Bad - Recalculating on every render:

public function render()
{
    return view('livewire.users', [
        'users' => User::where('active', true)->get(),
        'stats' => $this->calculateStats(),
        'summary' => $this->generateSummary()
    ]);
}

Good - Cache computed values:

#[Computed]
public function users()
{
    return User::where('active', true)->get();
}

#[Computed]
public function stats()
{
    return [
        'total' => $this->users->count(),
        'active' => $this->users->where('status', 'active')->count()
    ];
}

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

Performance Optimization

1. Lazy Load Heavy Components

<!-- Load immediately -->
<livewire:stats-widget />

<!-- Load after initial page render -->
<livewire:heavy-chart lazy />

2. Debounce Search Inputs

Bad - Updates on every keystroke:

<input wire:model.live="search">

Good - Debounced updates:

<input wire:model.live.debounce.500ms="search">

3. Use wire:key for Dynamic Lists

Bad - No keys:

@foreach($items as $item)
    <div>{{ $item->name }}</div>
@endforeach

Good - With keys:

@foreach($items as $item)
    <div wire:key="item-{{ $item->id }}">
        {{ $item->name }}
    </div>
@endforeach

4. Optimize Database Queries

Bad - N+1 queries:

public function render()
{
    return view('livewire.posts', [
        'posts' => Post::all() // Then accessing $post->user in view
    ]);
}

Good - Eager loading:

public function render()
{
    return view('livewire.posts', [
        'posts' => Post::with('user', 'comments')->limit(10)->get()
    ]);
}

5. Avoid Loading All Records

Bad:

'users' => User::all()

Good:

'users' => User::limit(50)->get()
// Or use pagination
'users' => User::paginate(25)

Validation

1. Real-Time Validation

class ContactForm extends Component
{
    public $email;

    protected $rules = [
        'email' => 'required|email'
    ];

    // Validate on blur
    public function updated($propertyName)
    {
        $this->validateOnly($propertyName);
    }

    public function submit()
    {
        $this->validate();
        // Submit logic
    }
}

2. Custom Error Messages

protected $messages = [
    'email.required' => 'Email is required.',
    'email.email' => 'Please enter a valid email address.',
];

3. Conditional Validation

public function rules()
{
    return [
        'email' => $this->isRequired ? 'required|email' : 'nullable|email',
        'phone' => 'required_if:contact_method,phone'
    ];
}

Security

1. Always Authorize Actions

Bad - No authorization:

public function delete($id)
{
    User::find($id)->delete();
}

Good - With authorization:

public function delete($id)
{
    $user = User::findOrFail($id);

    $this->authorize('delete', $user);

    $user->delete();

    session()->flash('message', 'User deleted successfully.');
}

2. Use Policies

// UserPolicy.php
public function update(User $user, User $model)
{
    return $user->id === $model->id || $user->isAdmin();
}

// In Component
public function update()
{
    $user = User::find($this->userId);

    $this->authorize('update', $user);

    $user->update([...]);
}

3. Validate File Uploads

use Livewire\WithFileUploads;

class FileUpload extends Component
{
    use WithFileUploads;

    public $document;

    public function save()
    {
        $this->validate([
            'document' => 'required|file|max:10240|mimes:pdf,doc,docx'
        ]);

        $path = $this->document->store('documents', 'private');
    }
}

Error Handling

1. Graceful Error Handling

public function save()
{
    try {
        $this->validate();

        // Save logic
        User::create([...]);

        session()->flash('message', 'Success!');
        session()->flash('type', 'success');

    } catch (\Exception $e) {
        Log::error('Save failed: ' . $e->getMessage());

        session()->flash('message', 'An error occurred. Please try again.');
        session()->flash('type', 'error');
    }
}

2. User-Friendly Messages

Bad:

session()->flash('message', $e->getMessage()); // Technical error

Good:

session()->flash('message', 'Unable to save user. Please check your inputs and try again.');
Log::error('User save error', ['exception' => $e, 'user_id' => $this->userId]);

UX Best Practices

1. Loading States

<button wire:click="save">
    <span wire:loading.remove wire:target="save">Save</span>
    <span wire:loading wire:target="save">
        <span class="spinner-border spinner-border-sm"></span>
        Saving...
    </span>
</button>

2. Disable During Loading

<button wire:click="save" wire:loading.attr="disabled">
    Save
</button>

3. Optimistic Updates

public function toggleComplete($taskId)
{
    // Update UI immediately (optimistic)
    $task = $this->tasks->firstWhere('id', $taskId);
    $task->completed = !$task->completed;

    // Then save to database
    Task::find($taskId)->update([
        'completed' => $task->completed
    ]);
}

4. Confirmation Dialogs

<button 
    wire:click="delete({{ $user->id }})" 
    wire:confirm="Are you sure you want to delete this user?"
>
    Delete
</button>

Testing

1. Unit Tests for Components

use Livewire\Livewire;

test('can create user', function () {
    Livewire::test(UserCreate::class)
        ->set('name', 'John Doe')
        ->set('email', 'john@example.com')
        ->call('save')
        ->assertDispatched('user-created')
        ->assertHasNoErrors();

    expect(User::where('email', 'john@example.com')->exists())->toBeTrue();
});

2. Test Validation

test('validates required fields', function () {
    Livewire::test(UserCreate::class)
        ->set('email', 'invalid-email')
        ->call('save')
        ->assertHasErrors(['email']);
});

3. Test Authorization

test('unauthorized user cannot delete', function () {
    $user = User::factory()->create();
    $otherUser = User::factory()->create();

    actingAs($user);

    Livewire::test(UserDelete::class)
        ->call('delete', $otherUser->id)
        ->assertForbidden();
});

Common Patterns

1. Modal Form

class UserModal extends Component
{
    public $showModal = false;
    public $userId;
    public $name;

    #[On('edit-user')]
    public function edit($userId)
    {
        $user = User::find($userId);
        $this->userId = $user->id;
        $this->name = $user->name;
        $this->showModal = true;
    }

    public function save()
    {
        $this->validate(['name' => 'required']);

        User::find($this->userId)->update([
            'name' => $this->name
        ]);

        $this->showModal = false;
        $this->dispatch('user-updated');
    }
}

2. Search with Filters

class UserSearch extends Component
{
    public $search = '';
    public $status = 'all';
    public $perPage = 10;

    public function render()
    {
        $query = User::query();

        if ($this->search) {
            $query->where('name', 'like', "%{$this->search}%");
        }

        if ($this->status !== 'all') {
            $query->where('status', $this->status);
        }

        return view('livewire.user-search', [
            'users' => $query->paginate($this->perPage)
        ]);
    }
}

3. Inline Editing

class InlineEdit extends Component
{
    public $editing = false;
    public $userId;
    public $name;

    public function edit()
    {
        $this->editing = true;
    }

    public function save()
    {
        $this->validate(['name' => 'required']);

        User::find($this->userId)->update(['name' => $this->name]);

        $this->editing = false;
    }

    public function cancel()
    {
        $this->editing = false;
        $this->name = User::find($this->userId)->name;
    }
}

Common Pitfalls

1. Don't Store Models Directly

Wrong:

public User $user;

Correct:

public $userId;

public function getUser()
{
    return User::find($this->userId);
}

2. Don't Use Closures in Properties

Wrong:

public $callback;

public function mount()
{
    $this->callback = function() { /* ... */ };
}

Correct:

public function executeCallback()
{
    // Logic here
}

3. Don't Forget wire:key in Loops

Wrong:

@foreach($items as $item)
    <div>{{ $item->name }}</div>
@endforeach

Correct:

@foreach($items as $item)
    <div wire:key="item-{{ $item->id }}">{{ $item->name }}</div>
@endforeach

4. Don't Overuse Real-Time Updates

Wrong - Every keystroke triggers update:

<input wire:model.live="search">

Better - Debounced:

<input wire:model.live.debounce.500ms="search">

Best - On blur:

<input wire:model.blur="name">

Debugging

1. Use Livewire DevTools

Install the browser extension:

2. Debug Specific Components

public function render()
{
    dd($this->all()); // Dump component state

    return view('livewire.component');
}

3. Log Livewire Requests

// In component
public function updated($name, $value)
{
    Log::info("Property {$name} updated", ['value' => $value]);
}

4. Check Network Tab

Open browser DevTools → Network → Filter by "livewire/update"

Look for:

  • Request payload
  • Response data
  • Errors in response

Accessibility

1. Use Proper ARIA Attributes

<button 
    wire:click="delete"
    aria-label="Delete user"
    wire:loading.attr="aria-busy=true"
>
    <i class="material-symbols-rounded">delete</i>
</button>

2. Keyboard Navigation

<div 
    tabindex="0" 
    wire:click="select"
    @keydown.enter="$wire.select()"
>
    Selectable item
</div>

3. Loading Announcements

<div 
    role="status" 
    aria-live="polite" 
    wire:loading
>
    Loading content...
</div>

Deployment Checklist

Before deploying Livewire apps:

  • [ ] Run npm run build for production assets
  • [ ] Set APP_ENV=production in .env
  • [ ] Enable caching: php artisan config:cache
  • [ ] Enable route caching: php artisan route:cache
  • [ ] Enable view caching: php artisan view:cache
  • [ ] Set LIVEWIRE_ASSET_URL if using CDN
  • [ ] Test all components in production mode
  • [ ] Monitor Livewire requests in production
  • [ ] Set up error tracking (Sentry, Bugsnag, etc.)

Resources


Summary

Key Takeaways:

  1. ✅ Keep components focused and single-purpose
  2. ✅ Optimize database queries and state management
  3. ✅ Always authorize actions and validate inputs
  4. ✅ Provide loading states and user feedback
  5. ✅ Use debouncing for search/filter inputs
  6. ✅ Add wire:key to dynamic lists
  7. ✅ Test your components thoroughly
  8. ✅ Follow accessibility best practices

Remember: Livewire makes building reactive interfaces simple, but great UX still requires thoughtful design and implementation.


Ready to build? Check out the [Components Guide]({{ route('docs.show', 'livewire/components') }}) for real-world examples! 🚀