PersistentComponentStateを使って1回の取得に最適化する
Blazorのプレレンダリングで発生する「二重データ取得」問題を解説し、PersistentComponentStateによる根本解決法を紹介します。
🧭 はじめに
Blazor Web Appにおいてプレレンダリングを有効にしたコンポーネントを実装したところ、ページロード時に同じデータを2回取得してしまい、パフォーマンスが著しく低下する問題に遭遇しました。
本記事では、
- なぜこの現象が発生するのか
- どのように回避すべきか
を、実際に動作するサンプルコードをもとに詳しく解説します。
動作確認環境: .NET 8以降(Blazor Web App テンプレート)
サンプルコード: GitHub - BlazorPrerenderingSample
📝 要約(この記事の結論)
Blazor Web Appでプレレンダリングを有効にすると、
OnInitializedAsync が サーバーとクライアントの両方で実行される ため、
データ取得が2回行われます。
これを回避するには、PersistentComponentState を使って
サーバーで取得したデータをクライアントに引き継ぐのが最適解です。
⚙️ プレレンダリング設定の基本構成
Blazorでは、プレレンダリングとインタラクティブ動作を制御するために
2つの設定レベルがあります。
| 設定方法 | 適用範囲 | 主な用途 | 特徴 |
|---|---|---|---|
| ページレベル設定 | 各ページに @rendermode を指定 |
学習・デモ・検証 | ページ遷移ごとに再プレレンダリングが発生する |
| グローバル設定 |
App.razor で一括設定 |
本番運用 | SPA内ページ遷移時に不要なプレレンダリングを回避 |
本記事では挙動をわかりやすく示すため「ページレベル設定」を使用します。
本番環境ではApp.razorでのグローバル設定を推奨 します。
🔍 Blazorの主要概念(復習)
プレレンダリング(Prerendering)
サーバーで静的HTMLを生成し、ユーザーに即時表示する仕組みです。
メリット
- 初回表示が高速
- SEOに有利
インタラクティブモード(Interactive Mode)
ユーザーの操作を処理できる状態。Blazorでは3種類あります。
- InteractiveServer: SignalR経由でサーバーと通信
- InteractiveWebAssembly: ブラウザ上で動作
- InteractiveAuto: 初回はServer、以降はWASM
@rendermode InteractiveServer
グローバル設定例(App.razor)
<!-- App.razor(本番運用推奨)-->
<Routes @rendermode="InteractiveServer" />
ページレベル設定例
<!-- Pages/Home.razor -->
@page "/home"
@rendermode InteractiveServer
<h3>ホーム</h3>
💣 問題の再現
以下のようなシンプルなTodoリストを実装したとします。
<!-- Pages/Problem.razor -->
@page "/problem"
@rendermode InteractiveServer
@inject ITodoService TodoService
<h3>Todo リスト</h3>
@if (todos == null)
{
<p>読み込み中...</p>
}
else
{
<ul>
@foreach (var todo in todos)
{
<li>@todo.Title</li>
}
</ul>
}
@code {
private List<Todo>? todos;
protected override async Task OnInitializedAsync()
{
todos = await TodoService.GetTodosAsync();
}
}
出力ログ:
[14:30:15.123] GetTodosAsync called // 1回目
[14:30:15.456] GetTodosAsync called // 2回目!
🧩 原因:プレレンダリングの仕組み
Blazorは以下の2フェーズで動作します:
フェーズ1:サーバー側プレレンダリング
フェーズ2:クライアント側インタラクティブ化
つまり、OnInitializedAsync() が2回呼ばれる仕様です。
✅ 解決方法1:PersistentComponentState(推奨)
サーバーで取得したデータをHTMLに埋め込み、クライアントに引き継ぎます。
<!-- Pages/Solution1.razor -->
@page "/solution1"
@rendermode InteractiveServer
@inject ITodoService TodoService
@inject PersistentComponentState ApplicationState
@implements IDisposable
<h3>Todo リスト</h3>
@if (todos == null)
{
<p>読み込み中...</p>
}
else
{
<ul>
@foreach (var todo in todos)
{
<li>@todo.Title</li>
}
</ul>
}
@code {
private List<Todo>? todos;
private PersistingComponentStateSubscription subscription;
protected override async Task OnInitializedAsync()
{
subscription = ApplicationState.RegisterOnPersisting(PersistTodos);
if (!ApplicationState.TryTakeFromJson<List<Todo>>("todos", out var restored))
{
Console.WriteLine("Fetching from API...");
todos = await TodoService.GetTodosAsync();
}
else
{
Console.WriteLine("Restored from cache");
todos = restored;
}
}
private Task PersistTodos()
{
ApplicationState.PersistAsJson("todos", todos);
return Task.CompletedTask;
}
public void Dispose() => subscription.Dispose();
}
結果
Fetching from API...
GetTodosAsync called // 1回だけ!
Restored from cache
ポイント
- プレレンダリングの高速性とSEOを維持
- API呼び出しは1回のみ
- 実運用に最も適した解法
✅ 解決方法2:プレレンダリングを無効化する
@page "/solution2"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
特徴
- 確実に1回だけAPI呼び出し
- ただし初回表示が遅く、SEO効果がない
本番では非推奨。
一部管理画面など「SEO不要な画面」でのみ有効。
✅ 解決方法3:OnAfterRenderAsync制御
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
todos = await TodoService.GetTodosAsync();
StateHasChanged();
}
}
特徴
- シンプルだが初回レンダリング時は空白表示
- 教材やデモ用途向き
🧭 ページレベル設定の落とし穴と使い分け
ページレベル設定の問題点
| ケース | 動作 | API呼び出し |
|---|---|---|
/todos に直接アクセス |
プレレンダリング + インタラクティブ化 | 2回 |
/home → /todos にSPA遷移 |
再度プレレンダリング + インタラクティブ化 | 2回 |
→ ページごとにプレレンダリングが再実行され、パフォーマンスが低下します。
使い分け指針
| 用途 | 推奨設定 |
|---|---|
| 学習・デモ | ページレベル設定 |
| 実運用 | グローバル設定(App.razor) |
| 一部だけインタラクティブにしたい | ページレベル指定で個別制御 |
推奨設定まとめ(本番)
<!-- App.razor -->
<Routes @rendermode="InteractiveServer" />
⚠ ページレベル設定は便利ですが、SPA遷移時のパフォーマンス低下に注意。
実運用では必ずグローバル設定を採用しましょう。
🧪 デバッグのコツ
protected override async Task OnInitializedAsync()
{
Console.WriteLine($"[OnInitializedAsync] {DateTime.Now:HH:mm:ss.fff}");
if (!ApplicationState.TryTakeFromJson<List<Todo>>("todos", out var restored))
{
Console.WriteLine("→ Fetching from API");
todos = await TodoService.GetTodosAsync();
}
else
{
Console.WriteLine("→ Restored from cache");
todos = restored;
}
}
- ブラウザコンソール + サーバーログ両方で呼び出し確認
- GitHubサンプルでは全ページにログが仕込まれています
📚 まとめ
| 方法 | API呼び出し | プレレンダリング | 初回表示 | SEO | 推奨度 |
|---|---|---|---|---|---|
| PersistentComponentState | 1回 | ✅ 有効 | ⚡ 高速 | ✅ | ⭐⭐⭐ |
| プレレンダリング無効化 | 1回 | ❌ 無効 | 🐢 遅い | ❌ | ⭐⭐ |
| OnAfterRenderAsync | 1回 | ❌ 無効 | 🐢 遅い | ❌ | ⭐ |
結論:
Blazorのプレレンダリング問題を解決するには、
PersistentComponentStateを使ってサーバーの状態を引き継ぐのが最も効率的です。
🔗 サンプルコード
👉 GitHub: satoshi3128/BlazorPrerenderingSample
# 実行方法
git clone https://github.com/satoshi3128/BlazorPrerenderingSample.git
cd BlazorPrerenderingSample
dotnet run
🙌 最後に
この記事は、実際の開発現場で遭遇した問題をもとに、
「なぜそうなるのか」を再現・分析した記録です。
技術が日々進化する中で、シニア世代のエンジニアとしても
現場で手を動かしながら学び続ける姿勢を大切にしています。
同じようにBlazorや.NETで開発している方の一助になれば幸いです。