Blazor ServerでOnInitializedAsyncがリロード時に2回実行される原因と対処:プリレンダリングの仕組みと解決パターン(.NET 8/9対応)

Blazor Server (.NET 8/9) で OnInitializedAsync() がページ再読込(特に Chrome のリロード)で 2 回走ってしまい、重い DB クエリが二重実行される――この現象は「プリレンダリング→対話モード」の二段階レンダリングが原因です。本稿では、なぜ起こるのかを仕組みから整理し、最短の回避策から本番運用で使える堅牢な実装パターンまで、具体的なコードと設計指針をまとめます。

目次

現象の整理:Blazor Server での「二重実行」

Blazor 8(Server / RenderMode.InteractiveServer)のページを Chrome でリロードすると、親子コンポーネントの OnInitializedAsync() が 2 回実行され、結果として DB クエリ・API 呼び出し・外部ストレージアクセスなどの重処理が二重に発生することがあります。これはバグではなく、次の二段階が標準動作として行われるためです。

  • プリレンダリング:HTTP 応答として最初の HTML をサーバーで生成して返す。
  • 対話モード(接続後レンダリング):SignalR で回線が確立された後、同じコンポーネントが再生成され、UI とイベントが「生きる」。

この二段階のそれぞれでコンポーネントが生成されるため、OnInitializedAsync() も「プリレンダ時」と「対話時」で 1 回ずつ呼ばれ、合計 2 回になる、という仕組みです。

最短の回避策:プリレンダリングを無効化する

「必ず 1 回だけ」実行させたい、かつ初期 HTML を即返す必要がない(もしくは SPA 的な初期表示で問題ない)なら、プリレンダリングを切るのが最短・確実です。

<!-- .NET 8 の <Routes> で全体に適用する例 -->
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />

ページ単位・コンポーネント単位で RenderMode を指定しても構いません。プリレンダを無効化すれば OnInitializedAsync() は 1 回だけになります。トレードオフは「初回の見た目が返る速さ(TTFB)」と SEO への寄与が減ることです。

プリレンダを活かしつつ二重取得を防ぐ実装パターン

「初回の HTML を速く返したい」や「SEO の都合でプリレンダは活かしたい」という要件は少なくありません。そこで、プリレンダを維持しつつデータ取得の二重実行を防ぐ代表的な 3 パターンを比較します。

アプローチ概要長所注意点実装コスト
短期キャッシュプリレンダ側の取得結果をメモリに 10〜30 秒保持。対話時はキャッシュを再利用既存コードに組み込みやすい/副作用が少ないプリレンダで DB を待つ=初回 HTML が重くなる可能性低
軽量初期化+遅延ロードOnInitializedAsync() では即返す。OnAfterRenderAsync(firstRender) で非同期ロード初回描画が速い/UX を壊さないローディング UI・再描画設計が必要中
プリレンダ判定で条件分岐プリレンダ中は DB を呼ばず、対話時だけ呼ぶ無駄な DB がゼロ実行環境の判定 API に依存。将来互換性の配慮が必要中

判定の基本:IComponentContext.IsConnected を使う

プリレンダか対話モードかの判定には、Blazor Server の IComponentContext.IsConnected を使うのが最も素直です。接続が確立されていれば true、プリレンダ中は false です。

@inject IComponentContext ComponentContext

@code {
    private bool IsInteractive => ComponentContext.IsConnected;

    protected override async Task OnInitializedAsync()
    {
        if (!IsInteractive)
        {
            // プリレンダ中:重い処理はスキップ or プレースホルダのみ
            return;
        }

        // 対話時にだけ重い処理を実行
        await LoadDataAsync();
    }
}

備考:HttpContext の有無で判定するテクニックは環境差や将来互換性の面で不安定になりがちです。接続状態の判断は IComponentContext.IsConnected に寄せるのが安全です。

実装例 1:軽量初期化+遅延ロード(推奨)

プリレンダは「見た目を素早く返す」ことが目的です。初回はダミーデータやスケルトンを表示し、回線確立後に本データを取って差し替えます。

@inject IComponentContext Ctx
@inject ILogger<WeatherList> Logger
@inject WeatherService Service

<div>
  @if (_loading)
  {
      <p>読み込み中…</p>
  }
  else if (_items is null)
  {
      <p>データが見つかりませんでした。</p>
  }
  else
  {
      <ul>
      @foreach (var x in _items)
      {
          <li>@x.Date:d - @x.Summary (@x.TemperatureC ℃)</li>
      }
      </ul>
  }
</div>

@code {
    private bool _loading = true;
    private IReadOnlyList<Weather>? _items;

    protected override Task OnInitializedAsync()
    {
        // プリレンダ中も即返す(描画をブロックしない)
        return Task.CompletedTask;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender && Ctx.IsConnected)
        {
            Logger.LogInformation("Interactive phase: fetching data...");
            try
            {
                _items = await Service.GetAsync();
            }
            finally
            {
                _loading = false;
                StateHasChanged();
            }
        }
    }
}

実装例 2:短期キャッシュ(GetOrCreate + 単一フライト)

プリレンダで取得したデータを短くキャッシュし、数秒後に無効化します。「単一フライト(duplicate suppression)」 を入れて同一キーに対する同時実行を 1 本に抑えるのがポイントです。

public interface IProductRepository
{
    Task<Product> GetAsync(string id, CancellationToken ct = default);
}

public sealed class CachedProductRepository : IProductRepository
{
    private readonly IProductRepository _inner;
    private readonly IMemoryCache _cache;
    private readonly ConcurrentDictionary<string, Lazy<Task<Product>>> _singleflight = new();

    public CachedProductRepository(IProductRepository inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public Task<Product> GetAsync(string id, CancellationToken ct = default)
        => _cache.GetOrCreateAsync($"product:{id}", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(15);
            var lazy = _singleflight.GetOrAdd(id,
                _ => new Lazy<Task<Product>>(() => _inner.GetAsync(id, ct), true));

            return lazy.Value.ContinueWith(t =>
            {
                _singleflight.TryRemove(id, out _);
                return t.Result;
            }, ct);
        })!;
}

この実装なら、プリレンダと対話時が同じキーにアクセスしても DB は 1 回だけです(少なくとも TTL 内)。TTL は要件に合わせて 5〜30 秒程度に調整します。

実装例 3:プリレンダと対話でデータを受け渡す(PersistentComponentState)

プリレンダで取得したデータを HTML に JSON として埋め込み、対話時に再利用します。ユーザーごとに分離されるため、他ユーザーと共有されません。

@inject IComponentContext Ctx
@inject PersistentComponentState AppState
@inject WeatherService Service

@code {
    private const string Key = "weather:latest";
    private Weather[]? _items;

    protected override async Task OnInitializedAsync()
    {
        // 既に埋め込み済みなら復元
        if (AppState.TryTakeFromJson(Key, out Weather[]? restored))
        {
            _items = restored;
            return;
        }

        if (!Ctx.IsConnected)
        {
            // プリレンダ中:データを取得して埋め込み登録
            _items = await Service.GetAsync();
            AppState.RegisterOnPersisting(() =>
            {
                AppState.PersistAsJson(Key, _items);
                return Task.CompletedTask;
            });
        }
        else
        {
            // 対話時:未埋め込みなら通常ロード
            _items = await Service.GetAsync();
        }
    }
}

この方式は「プリレンダで一度 DB を呼ぶが、対話時は再利用して二度目を回避」できます。埋め込む JSON が大きいと HTML 増量・パース遅延を招くため、必要最小限に絞るのがコツです。

「必ず 1 回は DB を呼ぶ」を保証する設計(かつ二重実行しない)

要件として「画面遷移ごとに最低 1 回は最新を取得したい。しかし 2 回以上は呼びたくない」というケースがあります。この場合、「単一フライト+短命キャッシュ」の組み合わせが有効です。

  • 単一フライト:同一キーに対し同時に来た複数のリクエストを 1 本に束ねる。
  • 短命キャッシュ:その 1 本の結果を短時間だけ再利用。TTL を短く設定して「毎ページで最低 1 回」を満たす。

さらに厳密に制御したい場合は、「ナビゲーションごとに世代(Generation)を進める」戦略を加えると堅牢です。

public sealed class PageGeneration
{
    private int _gen = 0;
    public int Current => Volatile.Read(ref _gen);
    public void Next() => Interlocked.Increment(ref _gen);
}

public sealed class GenerationAwareCache<TKey, TValue>
{
    private readonly IMemoryCache _cache;
    private readonly PageGeneration _generation;
    private readonly Func<TKey, CancellationToken, Task<TValue>> _loader;

    public GenerationAwareCache(IMemoryCache cache, PageGeneration generation,
        Func<TKey, CancellationToken, Task<TValue>> loader)
    {
        _cache = cache;
        _generation = generation;
        _loader = loader;
    }

    public Task<TValue> GetAsync(TKey key, CancellationToken ct)
        => _cache.GetOrCreateAsync((key, _generation.Current), e =>
        {
            // 世代が変われば自動的に別エントリとなる
            e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
            return _loader(key, ct);
        })!;
}

ページ遷移時(NavigationManager.LocationChanged)に PageGeneration.Next() を呼ぶだけで、「遷移ごとに 1 回はロード」しつつ、同一遷移内の重複実行は抑止できます。

実装の落とし穴とベストプラクティス

落とし穴

  • OnInitializedAsync() で重い処理を同期的に待つ:プリレンダ中の応答が遅くなり、TTFB が悪化します。
  • プリレンダと対話で別リソースを読んでしまう:一方は DB、もう一方は API を呼ぶ、などキーの統一が崩れると重複が再発します。
  • キャッシュの排他を考慮しない:同じキーに対して同時に複数スレッドがロードすると、結局二重実行になります。
  • HttpContext 判定に依存:環境差・将来の変更に弱く、誤検知の温床になります。

ベストプラクティス

  • プリレンダ維持なら「軽量初期化+遅延ロード」か「PersistentComponentState」を第一候補に。
  • 常に重処理は サービス層 に寄せ、コンポーネントからは 一貫したキー で呼び出す。
  • 単一フライト と 短命キャッシュ を組み合わせ、二重実行を物理的に抑止。
  • ログに フェーズ(PreRender / Interactive) と キー を必ず出す。

.NET 9 以降の注意点(RenderMode と検出)

.NET 9 ではレンダリングモードの表現や API が整理され、バージョンによっては内部 API の廃止・置き換えが進んでいます。以下を目安に設計すると移行が楽です。

  • 「プリレンダを切る or プリレンダ時は重処理を呼ばない」という方針自体は変わりません。
  • 環境判定は 公開かつ公式に案内されている API を使う(IComponentContext.IsConnected/レンダーモードの設定)。
  • 内部的な null チェックや未公開型への依存は避ける。

「Chrome だけ?」という疑問への回答

現象の根因は Blazor Server の二段階レンダリングです。Chrome のリロードで顕在化しやすいだけで、原理的にはブラウザ非依存です。ネットワーク条件や開発サーバーのホットリロード挙動によっても観測頻度は変わります。

検証のための最小サンプル

以下は「プリレンダでスキップ、対話時にだけロード」する最小サンプルです。DB の代わりに 1 秒待つ処理で擬似的に重さを再現しています。

@page "/demo"
@inject IComponentContext Ctx
@inject ILogger<Demo> Log

<h3>Demo</h3>
<p>Phase: @(Ctx.IsConnected ? "Interactive" : "PreRender")</p>
<p>Message: @_message</p>

@code {
    private string _message = "Ready";

    protected override async Task OnInitializedAsync()
    {
        if (!Ctx.IsConnected)
        {
            _message = "PreRender: skip heavy work";
            return;
        }

        _message = "Interactive: loading...";
        await Task.Delay(1000);
        _message = "Interactive: done";
    }
}

プロダクション向けチェックリスト

  • RenderMode を明示(prerender: true/false の意図を決める)。
  • 重処理は OnAfterRenderAsync(firstRender) で非同期ロードに寄せる。
  • サービス層に 単一フライト と 短命キャッシュ を実装。
  • 可能なら PersistentComponentState で「一度だけ取得→埋め込み→復元」。
  • ログに「フェーズ」「キー」「所要時間」「ヒット/ミス」を出す。
  • TTL は 5〜30 秒で A/B し、TTFB と体感の最適点を探る。
  • 3rd パーティ UI の初期化が OnInitialized 依存のときは、初期化とデータロードを分離。

設計パターン別のコード断片集

1) ルート全体のプリレンダ無効化(.NET 8 の <Routes>)

<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />

2) ページ単位でプリレンダ制御

@rendermode new InteractiveServerRenderMode(prerender: false)

3) 画面スケルトン & 遅延ロードの UI 断片

<div class="skeleton">読み込み中…</div>

4) IMemoryCache の基本パターン

public static class MemoryCacheExtensions
{
    public static Task<T> GetOrCreateAsync<T>(this IMemoryCache cache, string key,
        Func<ICacheEntry, Task<T>> factory, TimeSpan ttl)
        => cache.GetOrCreateAsync(key, e => { e.AbsoluteExpirationRelativeToNow = ttl; return factory(e); })!;
}

5) NavigationManager で世代を進める

@inject NavigationManager Nav
@inject PageGeneration Gen

@code {
protected override void OnInitialized()
{
Nav.LocationChanged += (_, __) => Gen.Next();
}
}

パフォーマンス指針(実運用)

  • プリレンダは「速く見せる」ため。重い DB 呼び出しは避ける。必要ならキャッシュや最小限データで 1〜2 秒以内を目安に。
  • 対話時ロードは 並列化(上位・下位コンポーネントが別データを取るときは Task.WhenAll)。
  • 長い一覧は ページング・仮想化 を使い、初回はメタデータだけ。
  • ロギング/メトリクス(EventCounters, ILogger)で「二重実行ゼロ」を可視化。

よくある質問(FAQ)

Q. プリレンダでも絶対に DB を呼びたい。二重実行させずに済みますか?

A. はい。プリレンダで取得 → PersistentComponentState で埋め込み → 対話時は復元(未埋め込み時のみ取得)で実現できます。

Q. NavigateTo(..., forceLoad: true) なら二重実行は避けられますか?

A. これは「フルページロードを強制する」ため、場面によりプリレンダと対話の再実行が発生します。根本対策にはなりません。プリレンダ判定+条件分岐や遅延ロードを使ってください。

Q. 子コンポーネントでも二重実行します。親で抑えられますか?

A. 親子でデータ取得を重ねると重複しやすいです。サービス層で単一フライトにしておけば、どのコンポーネントから呼んでも物理的に 1 回に抑えられます。

まとめ

  • 原因:Blazor Server のプリレンダ → 対話モードという二段階レンダリング。
  • 最短回避:プリレンダを無効化(prerender: false)。
  • プリレンダ維持の設計:軽量初期化+遅延ロード(推奨)/短期キャッシュ+単一フライト/PersistentComponentState の活用。
  • 「1 回は必ず DB」は、世代付きキャッシュで遷移ごと 1 回を保証しつつ重複実行を防ぐ。
  • 将来互換性:公開 API(IComponentContext.IsConnected と RenderMode 設定)を利用し、内部実装への依存は避ける。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次