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」が重くなるのか
「検索ボックスに文字を打つたびに一覧が絞り込まれる」要件を素の構成で実装すると、おおよそ次の作業が発生します。
- 一覧取得・検索の API エンドポイントを作る
- フロント側で入力をデバウンスして fetch を投げる
- レスポンスを受けて state を更新・再レンダリングする
- ローディング状態・エラー状態をフロントで管理する
- フロントとサーバで「検索条件」という同じ概念を二重に持つ
機能自体は小さいのに、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 は魔法ではなく、次のことをしています。
- 初回は通常の HTML としてコンポーネントを描画する
- コンポーネントの状態をシリアライズして HTML に埋め込む
wire:の操作が起きると、状態とともにサーバへリクエストを送る- サーバはクラスを復元し、該当メソッドを呼び、
render()を再実行して HTML を返す - クライアントのランタイム(+ 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 タブで往復を観察するところから始めてみてください。
