Blazor向けにコンポーネント間で状態を共有する状態管理ライブラリを作った
はじめに
Blazorにおいてメモリ内で利用する状態(サービス,クラスインスタンス)はどのように管理していますか?
Blazorerの中にはReactやVueなどのJavascriptフレームワークを利用したことがない方も多いと思います.
BlazorにおけるUIの更新方法ですが,
Blazorは,WPFやUWPなどのMVVMのアーキテクチャとは根本的に違い,
どちらかというとReactやVueに近いアーキテクチャを持っています.
私はBlazorにおいて,MVVMで利用していたプラクティスは必ずしも理想的ではないと考えています.
ReactやVueの世界ではコンポーネント(UI)間で状態を共有する際に別途状態管理(Store)ライブラリを導入するケースが多いです.
例: Reactの場合
- Redux
- Flux
- Recoil
- Mobx
例: Vueの場合
- Vuex
- Pinia
中にはReduxのような厳密なルールのものから,MobxやRecoilのようにシンプルなものまであります.
Blazorの場合はどうでしょうか.
コンポーネント間で状態を共有するには,ParameterとEventCallbackでバケツリレーをしたり,
後述しますが,公式ドキュメントで説明されているようなメモリ内コンテナサービスで管理する方法などがあります.
ただし,ボイラープレートを忘れるとメモリリークの危険があったり,デメリットもあります.
状態管理ライブラリ
それらの面倒なことを考えずに,コンポーネント間で安全に状態を共有できるようにしたかったのと同時に,単一方向データフローかつイミュータブルな状態管理で,ReDo/UnDo対応,Redux Devtoolにも対応させたかったので,ライブラリを作成しました.
状態の扱いや考え方はReactの状態管理にインスパイアされています.
今回はそれの紹介をします.(⭐よろしくね!)
特徴
- イミュータブル
- データを単一方向に設計
- Redux Devtool対応
- ReDo/UnDo対応
- すべてのStoreの状態は1つの状態ツリーに集約することができる
単一方向データフローにすることで、
データが一方向に流れるように設計されることで、
データの流れが予測可能になります.
またイミュータブルな状態として扱うことで,
状態の予期せぬ変化を防ぎ,
変化の予測およびデバッグやテストが容易になります。
その他ライブライリ
Reactでよく利用されているReduxのBlazor向けの実装のFluxorや,
その他実装はありますが,
FluxorはFluxやReduxのBlazor向け実装で厳密なルールでの利用前提なので,
今回は割愛します.
Fluxor
変更通知を呼び出す方法
状態管理ライブラリなしでメモリ内コンテナに状態を保存する場合,
以下のBlazor公式ドキュメントのような方法があります.
ただしこの方法では以下のボイラープレートが必要です.
特にStateContainer.OnChange -= StateHasChanged;
を忘れるとメモリリークします.
これをもっと楽に行うというのが今回紹介するライブラリです.
@implements IDisposable
@inject StateContainer StateContainer
@code{
protected override void OnInitialized() {
StateContainer.OnChange += StateHasChanged;
}
public void Dispose() {
StateContainer.OnChange -= StateHasChanged;
}
}
ライブラリの紹介とチュートリアル
ライブラリのリポジトリ
動作デモページ
ドキュメント
サンプルプロジェクト
詳しい使い方はドキュメントを参照してください.
状態を管理するサービスクラスを以後,Storeと呼びます.
インストール
dotnet add package Memento.Blazor
状態(State)を定義する
まずは管理したい状態(State)をC#のレコードとして定義しましょう,
状態はイミュータブルにする必要があります.
状態を変更するにはプロパティを直接変更せずに新しいインスタンスを作成します.
これを簡略化するためにC#のレコードとwith式を利用します.
State内のCollectionは変更しない前提なのでImmutableArrayかImmutableListが望ましいでしょう.
Listとかだと誤って変更してしまう可能性があります.
public record AsyncCounterState {
public int Count { get; init; } = 0;
public ImmutableArray<int> History { get; init; } = ImmutableArray.Create<int>();
public bool IsLoading { get; init; } = false;
}
このように省略しても大丈夫です
public record CounterState2(
int Count,
ImmutableArray<int> History,
bool IsLoading
);
Storeを定義する
非同期にカウントアップするStoreの場合は以下のようになります.
実際のユースケースではAPIの呼び出し処理をしたりすることが想定されます.
public class CounterStore : Store<CounterState> {
public CounterStore() : base(() => new CounterState()) {
}
public async Task CountUpAsync() {
Mutate(State with { IsLoading = true });
await Task.Delay(500);
var count = State.Count + 1;
Mutate(State with {
Count = count,
History = State.History.Add(count),
});
Mutate(State with { IsLoading = false });
}
public void SetCount(int num) {
Mutate(state => state with {
Count = num,
History = state.History.Add(num),
});
}
}
CounterStoreはStoreを継承して型引数にStateの型を指定しています.
基底クラスのコンストラクタに初期のStateのインスタンスを指定しましょう.
StoreのStateを変更するにはMutate()に新しいStateインスタンスを指定しましょう.
C#のレコードとwith式を利用した
以下の呼び出しでIsLoadingがtrueになります.
Mutate(State with { IsLoading = true });
また,ラムダ式を利用して以下のように記述するオーバーロードもあります.
Mutate(state => state with { IsLoading = true });
詳しいチュートリアルは英語ですが以下に用意しました
Blazorコンポーネントから利用する
まずProgram.csにライブラリの初期化処理を登録します
特にBlazor Serverの場合はisScopedをtrueにしておかないとすべてのユーザーで同じStoreを参照してしまうので注意が必要です.
builder.Services
// ライブラリをDIコンテナへ登録
.AddMemento(isScoped: false)
// CounterStoreを登録します
// isScopedはDIコンテナへの登録をAddScopedでするかどうか,falseの場合はAddSingleton
.AddStore<CounterStore>(isScoped: false)
// アセンブリをスキャンしてすべてのストアを登録します
// 対象のアセンブリに含まれるStoreはAddStoreで登録する必要ありません
.ScanAssemblyAndAddStores(typeof(Program).Assembly, isScoped: false);
_Imports.razorにnamespaceを登録
@using Memento.Core
@using Memento.Blazor
App.razorにコンポーネントのライフサイクルに合わせた初期化を行うために <MementoInitializer />
を追加
<MementoInitializer />
<Router AppAssembly="@typeof(App).Assembly">
...
</Router>
Counter.razorを以下のように変更.
ポイントとしては@inherits ObserverComponent
を忘れないことです.
これを忘れるとStoreのStateの変更を検知できません.
@page "/counter"
@inherits ObserverComponent
@inject CounterStore CounterStore
<PageTitle>Counter</PageTitle>
<div>
<h1 class="mt-5">Counter</h1>
<h2>Current count: @CounterStore.State.Count</h2>
<p>Loading: @CounterStore.State.IsLoading</p>
@_countToSet
<div class="mt-5">
<h3>Count up async with histories</h3>
<button class="mt-3 btn btn-primary" onclick="@CountUpAsync">Count Up Async</button>
<p class="mt-3 mb-0">Histories</p>
<div class="d-flex">
@foreach (var item in string.Join(", ", CounterStore.State.History)) {
@item
}
</div>
</div>
<div class="mt-5">
<h3>Set count</h3>
<input @bind-value="_countToSet" />
</div>
<button class="mt-3 btn btn-primary" @onclick="SetCount">Set</button>
</div>
@code {
int _countToSet = 100;
async Task CountUpAsync() {
await CounterStore.CountUpAsync();
}
void SetCount() {
CounterStore.SetCount(_countToSet);
}
}
ボタンをクリックするとカウントアップして履歴が表示されます.
Stateが自動で更新されていることを確認することができます.
もちろんコンポーネント間での状態の変更検知は自動で行われます.
以下のコンポーネントを作成します.
@inject CounterStore CounterStore
<button class="mt-3 btn btn-primary" onclick="@CountUpAsync">Count Up Async</button>
@code {
async Task CountUpAsync() {
await CounterStore.CountUpAsync();
}
}
そして以下の部分を
<button class="mt-3 btn btn-primary" onclick="@CountUpAsync">Count Up Async</button>
以下のように変更
<CountUpButton />
これでコンポーネントをまたいでもStateが自動で更新されていることを確認することができます.
外部サービスクラスから状態を変更する
以下のようにStoreクラス外からもMutate()を呼び出すことで状態を変更することができます.
public class HogeService {
readonly CounterStore _counterStore;
public HogeService(CounterStore counterStore) {
_counterStore = counterStore;
}
public async Task CountUpAsync() {
await Task.Delay(100);
_counterStore.Mutate(state => state with { Count = state.Count + 1 });
}
}
終わりに
個人的にはBlazorプロジェクトでは必ず使うライブラリとなってますので積極的にメンテナンスしていこうと考えてます.
数か月間使ってみた所感としては状態をStoreに集約して管理すると予期せぬバグも減り開発しやすくなったと思います.
今回はシンプルな一例でした.
他にもサンプルやReDo/UnDoの機能や,ReduxDevToolを利用してStoreを可視化してデバッグしたりもできるので,Githubのリポジトリをみてください.
ReduxDevToolの使い方は別記事にしました
Discussion