Livewireで作るリアルタイムUIの基礎:フルスタックにJSを書かない開発

概要

Laravel で「入力に応じて即座に画面が変わる」ようなリアルタイムUIを作ろうとすると、従来はフロントエンドに React や Vue を別途立て、API を設計し、状態をフロントとサーバの両方で持つ構成になりがちでした。機能自体は小さいのに重装備になる、というのはよくある悩みです。

Livewire は、この二重管理をなくすためのフレームワークです。UI の状態とロジックを PHP のクラス側に置いたまま、ブラウザ操作に応じてサーバとやり取りし、DOM を差分更新するという考え方で動きます。結果として、カウンタ・インクリメンタル検索・フォームのリアルタイムバリデーションといった動的UIを、ほぼ JavaScript を書かずに実装できます。

本記事では入門として、Livewire が解決する課題・コンポーネントの基本構造・主要ディレクティブ・動作の仕組み・注意点を、コード例つきで整理します。なお本記事は Livewire 4 系を前提としています。

環境

  • PHP 8.2 以上(現行の最新は 8.5 系)
  • Laravel 10 以上(現行は Laravel 13 系。Livewire 4 もこの世代を主な対象としています)
  • Livewire 4 系

インストールは Composer で行います。現在このコマンドで入るのは Livewire 4 系です。

composer require livewire/livewire

なぜ「ちょっとした動的UI」が重くなるのか

「検索ボックスに文字を打つたびに一覧が絞り込まれる」要件を素の構成で実装すると、おおよそ次の作業が発生します。

  1. 一覧取得・検索の API エンドポイントを作る
  2. フロント側で入力をデバウンスして fetch を投げる
  3. レスポンスを受けて state を更新・再レンダリングする
  4. ローディング状態・エラー状態をフロントで管理する
  5. フロントとサーバで「検索条件」という同じ概念を二重に持つ

機能自体は小さいのに、API設計・フロント状態管理・二重のバリデーションがついてきます。根本原因は、状態がブラウザとサーバの2か所に分裂していることです。分かれているから同期が必要になり、同期のために API と共有の型定義が要る。ここがコストの発生源です。

Livewire は「状態の正本をサーバ側(PHP プロパティ)に一本化する」ことでこの分裂を解消します。

コンポーネントの基本構造

Livewire のコンポーネントは次の2ファイルで構成されます。

  • PHP クラス(app/Livewire/ 配下)… プロパティ(状態)とメソッド(アクション)
  • Blade ビュー(resources/views/livewire/ 配下)… プロパティを描画し、wire: でイベントを結合

コンポーネントは Artisan で生成します。

php artisan make:livewire Counter

実装例

1. 最小のカウンタ

まずは状態がサーバ側にあることを体感するカウンタです。

<?php
// app/Livewire/Counter.php

namespace App\Livewire;

use Livewire\Component;

class Counter extends Component
{
    // 状態の正本。ブラウザではなくサーバに置かれる
    public int $count = 0;

    public function increment(): void
    {
        $this->count++;
    }

    public function render()
    {
        return view('livewire.counter');
    }
}
{{-- resources/views/livewire/counter.blade.php --}}
<div>
    <button wire:click="increment">+</button>
    <span>{{ $count }}</span>
</div>

wire:click="increment" は「ボタン押下時にサーバの increment を呼べ」という意味です。押すと Livewire がサーバへリクエストを送り、更新後の $count を反映した HTML を受け取り、変わった部分だけ DOM を書き換えます。JavaScript は1行も書いていません。

なお、Blade のルート要素は1つにする必要があります。トップレベルに兄弟要素を並べると更新対象を特定できずエラーになります。

2. wire:model によるインクリメンタル検索

<?php
// app/Livewire/SearchUsers.php

namespace App\Livewire;

use App\Models\User;
use Livewire\Component;

class SearchUsers extends Component
{
    public string $search = '';

    public function render()
    {
        $users = User::query()
            ->when($this->search !== '', function ($query) {
                $query->where('name', 'like', "%{$this->search}%");
            })
            ->orderBy('name')
            ->limit(20)
            ->get();

        return view('livewire.search-users', [
            'users' => $users,
        ]);
    }
}
{{-- resources/views/livewire/search-users.blade.php --}}
<div>
    <input type="text" wire:model.live.debounce.300ms="search" placeholder="名前で検索">

    <ul>
        @forelse ($users as $user)
            <li>{{ $user->name }}</li>
        @empty
            <li>該当するユーザーがいません</li>
        @endforelse
    </ul>
</div>

修飾子がポイントです。

  • .live … 入力のたびにサーバへ同期する(Livewire では修飾子なしの wire:model は遅延バインドがデフォルトで、リアルタイム更新には .live が必要)
  • .debounce.300ms … 300ミリ秒入力が止まってから送る(打鍵ごとのリクエスト連打を防ぐ)

render() はリクエストのたびに再実行されるため、$this->search に応じた一覧が毎回計算されます。検索条件の正本はサーバの $search 1か所です。

3. リアルタイムバリデーション付きフォーム

<?php
// app/Livewire/CreatePost.php

namespace App\Livewire;

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

class CreatePost extends Component
{
    #[Validate('required|min:3|max:100')]
    public string $title = '';

    #[Validate('required|min:10')]
    public string $body = '';

    public function save(): void
    {
        $validated = $this->validate();

        Post::create($validated);

        $this->reset(['title', 'body']);

        session()->flash('status', '投稿を保存しました。');
    }

    public function render()
    {
        return view('livewire.create-post');
    }
}
{{-- resources/views/livewire/create-post.blade.php --}}
<div>
    @if (session('status'))
        <p>{{ session('status') }}</p>
    @endif

    <form wire:submit="save">
        <div>
            <input type="text" wire:model.live.blur="title" placeholder="タイトル">
            @error('title') <span>{{ $message }}</span> @enderror
        </div>

        <div>
            <textarea wire:model.live.blur="body" placeholder="本文"></textarea>
            @error('body') <span>{{ $message }}</span> @enderror
        </div>

        <button type="submit" wire:loading.attr="disabled">保存</button>
        <span wire:loading>保存中...</span>
    </form>
</div>
  • #[Validate(...)] 属性でプロパティに直接ルールを書けます(Livewire 3 以降で使える属性ベースの書き方)。
  • wire:model.live.blur でフォーカスが外れたタイミングにサーバへ同期し、検証を走らせます。Livewire 4 では .blur/.change はクライアント側の同期タイミングを制御する修飾子に変わり、.blur 単体ではサーバへリクエストが飛びません。ブラー時にサーバ検証を走らせたい場合は、ここでのように .live を併せて付けます(v3 の wire:model.blur に相当する挙動は v4 では wire:model.live.blur)。
  • wire:submit="save" はデフォルトで preventDefault 相当の挙動になります。
  • wire:loading 系で通信中だけ表示を変えたりボタンを無効化できます。すべて JS なしです。

動作の仕組み

Livewire は魔法ではなく、次のことをしています。

  1. 初回は通常の HTML としてコンポーネントを描画する
  2. コンポーネントの状態をシリアライズして HTML に埋め込む
  3. wire: の操作が起きると、状態とともにサーバへリクエストを送る
  4. サーバはクラスを復元し、該当メソッドを呼び、render() を再実行して HTML を返す
  5. クライアントのランタイム(+ Alpine.js)が旧DOMと新DOMを差分比較し、変わった箇所だけ置き換える

つまり「状態はサーバ、描画結果の差分適用はクライアント」という役割分担です。

確認方法

// routes/web.php
use App\Livewire\SearchUsers;

Route::get('/search', SearchUsers::class);

開発者ツールの Network タブを開いた状態で操作すると、操作のたびに Livewire のエンドポイント(/livewire/update)へ XHR が飛び、HTML 差分で画面が更新される様子が観察できます。これが「JS を書かずにリアルタイム更新が動く」実体です。

注意点

  • リクエストはこまめに飛ぶ:.live を全入力に付けるとリクエストが増える。必要なものだけ .live、重いものは .debounce を付ける。
  • プロパティのシリアライズ制約:Eloquent モデルや複雑なオブジェクトを直接プロパティに持たせない。一覧データは render() の引数で渡し、プロパティには ID やスカラー値を持たせる。
  • ルート要素は単一。
  • 大きな状態を持たない:状態は毎リクエスト往復するので、巨大な配列は通信量を膨らませる。
  • 向き不向きがある:本格 SPA・オフライン対応・超高頻度更新は専用フロントが適する。Livewire は「サーバ主導でちょうどいい動的UI」向け。

まとめ

  • Livewire は状態の正本をサーバ(PHP プロパティ)に一本化し、フロント・サーバ間の状態分裂をなくす。
  • wire:click / wire:model / wire:submit で、JS を書かずに動的UI・バリデーション・ローディング表示が作れる。
  • 仕組みは「状態を往復させ、返ってきた HTML 差分を DOM に適用する」だけで、理解すればデバッグもしやすい。
  • 一方でリクエスト頻度・シリアライズ制約・状態サイズには注意が必要。

小さな管理画面やフォーム周りから Livewire を導入すると、フロントの重装備を省きつつ体験を上げられます。まずはカウンタと検索フォームを動かし、Network タブで往復を観察するところから始めてみてください。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。