注文サイトのようなクライアントサイドの Web アプリのときに、画面遷移をしてもカート機能のメモリのように状態を保持しておきたいときが悩みどころです。
書籍のほう https://amzn.asia/d/0cim4yUX では抜けてしまったのですが、所謂「ASP.NET Core Blazor 状態管理の概要 」https://learn.microsoft.com/ja-jp/aspnet/core/blazor/state-management/?view=aspnetcore-10.0&source=recommendations あたりを参考にして実装していきます。
カート機能のサービス
いわゆる CartItem を内部に持つクラスを作ります。
public class CartItem
{
public int ProductId { get; set; }
public string Name { get; set; } = "";
public double Price { get; set; }
public int Quantity { get; set; }
}
public class CartService
{
private List<CartItem> _items = new();
public List<CartItem> Items
{
get => _items;
}
// カートに追加
public void Add( int productId, string name, double price )
{
var existing = _items.FirstOrDefault(x => x.ProductId == productId);
if ( existing is not null )
{
existing.Quantity++;
}
else
{
_items.Add(new CartItem { ProductId = productId, Name = name, Price = price, Quantity = 1 });
}
}
// カートから削除
public void Remove( int productId )
{
var existing = _items.FirstOrDefault(x => x.ProductId == productId);
if ( existing is not null )
{
_items.Remove( existing );
}
}
// カートをクリア
public void Clear( )
{
_items.Clear();
}
}
Razor コンポーネントを横断するための CartService クラスを作成します。いわゆる、グローバルなシングルトンなクラスです。React や Vue あたりならば、useState とか Pinia を使っているところです。
Blazor アプリケーションにひとつだけあればいいので、builder.Services.AddScoped を使って追加しておきます。
Program.cs に
using BlazorOrderApp.Components;
using BlazorOrderApp.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddScoped<CartService>(); // ★ここを追加
こうすることで各 Razor コンポーネントのほうで @inject BlazorOrderApp.Services.CartService Cart のような形でカート機能のサービスを参照できます。このテクニックは HttpClient のインジェクションでも利用されています。
「ASP.NET Core Blazor 状態管理の概要」によれば、クライアント builder.Services.AddSingleton のときとサーバー uilder.Services.AddScoped のときとで差がでるので、これは後で確認します。実は JavaScript の localStorage を呼び出すパターンも実装が可能です。
カート画面 CartView.razor の実装
CartService を injection してカート画面を作ります。
単品の増減と削除ボタンをつけます。
@page "/cart"
@rendermode InteractiveServer
@inject NavigationManager Navigation
@inject HttpClient Http
@inject IJSRuntime JSRuntime
@inject BlazorOrderApp.Services.CartService Cart
<div class="container py-4">
<div class="mb-4">
<h2 class="fw-bold text-danger">🍣 カート一覧</h2>
</div>
<div>
@foreach( var it in Cart.Items ) {
<div>@it.Name : @it.Price 円
<button class="btn btn-primary" @onclick="() => Decriment(it)">-</button>
@it.Quantity 個
<button class="btn btn-primary" @onclick="() => Incriment(it)" >+</button>
<button class="btn btn-danger" @onclick="() => Remove(it)" >削除</button>
</div>
}
</div>
<div>
合計: @sum 円
</div>
</div>
@code {
private double sum => Cart.Items.Sum( it => (int)it.Price * it.Quantity );
private void Incriment( Services.CartItem it ) {
if ( it.Quantity >= 8 ) return;
it.Quantity++;
}
private void Decriment( Services.CartItem it ) {
if ( it.Quantity <= 0 ) return ;
it.Quantity--;
}
private void Remove(Services.CartItem it) {
Cart.Items.Remove( it );
}
}

この状態で「+」ボタンと「-」ボタン、「削除」ボタンの動きを確認して、メソッドを作っておきます。
その後で、Claude Code を使ってレイアウトの直し。
<div class="container py-4" style="max-width: 720px;">
<h2 class="fw-bold mb-4">🍣 カート</h2>
@if (!Cart.Items.Any())
{
<div class="text-center text-muted py-5">
<p class="fs-5">カートに商品がありません。</p>
<a href="/" class="btn btn-outline-primary mt-2">メニューへ戻る</a>
</div>
}
else
{
<div class="card shadow-sm mb-4">
<ul class="list-group list-group-flush">
@foreach (var it in Cart.Items)
{
<li class="list-group-item py-3">
<div class="d-flex align-items-center gap-3">
<div class="flex-grow-1">
<span class="fw-semibold">@it.Name</span>
<span class="text-muted ms-2">@it.Price 円</span>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-secondary btn-sm" style="width:2rem;" @onclick="() => Decriment(it)">−</button>
<span class="fw-bold" style="min-width:1.5rem; text-align:center;">@it.Quantity</span>
<button class="btn btn-outline-secondary btn-sm" style="width:2rem;" @onclick="() => Incriment(it)">+</button>
</div>
<div class="text-end" style="min-width:5rem;">
<span class="fw-semibold">@((int)it.Price * it.Quantity) 円</span>
</div>
<button class="btn btn-outline-danger btn-sm" @onclick="() => Remove(it)">削除</button>
</div>
</li>
}
</ul>
</div>
<div class="card shadow-sm">
<div class="card-body d-flex justify-content-between align-items-center">
<span class="fs-5 fw-bold">合計</span>
<span class="fs-4 fw-bold text-danger">@sum 円</span>
</div>
<div class="card-footer d-flex justify-content-end gap-2">
<a href="/" class="btn btn-outline-secondary">買い物を続ける</a>
<button class="btn btn-danger px-4">注文する</button>
</div>
</div>
}
</div>

AddSingleton の時の問題
一見すると AddScoped でも AddSingleton でも良いような気がするのですが、AddSingleton の場合は重大な問題があります。シングルトンとなるのが「アプリケーション全体」となるために、別々のブラウザで開いていても、同じデータを共有するんですよね。ちょっと不思議な動きではありますが、左の Chrome で入力したカート画面を、そのまま Edge で開くと同じカートの内容が表示されます。ブラウザ異なるのでセッション共有されていません。

おそらく AddSingleton の場合は「サーバーサイドでシングルトンになる」という意味になります。まあ、Blazor サーバーのほうは、サーバーにひとつだけ存在するので、そこにメモリがある、というイメージです。
これは、カート機能としては不味いですね。
AddScoped を使うと、セッションが別になるのでブラウザが異なるとカートも異なります。これが自然な状態です。

AddScoped の時の問題
そうなるとカート機能を作るならば AddScoped を使うことになるのですが、これもちょっと問題があります。Blazor がクライアントサイドのアプリケーションになるので、ブラウザでリロードをするとカートの中身が消えてしまいます。
リロード前

リロード後

これは wasm 版の Blazor も一緒だし、React/Vue の場合もリロードをした場合は同じ挙動になるので、仕方がないといえば仕方がないのですが…まあ、カート機能としてはダメですね。
そうなると、ブラウザの localStorage を使うことになるので、これを次回試します。
