💽

Blazor向けにコンポーネント間で状態を共有する状態管理ライブラリを作った

2023/08/08に公開

はじめに

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でバケツリレーをしたり,
後述しますが,公式ドキュメントで説明されているようなメモリ内コンテナサービスで管理する方法などがあります.

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/state-management?view=aspnetcore-7.0&pivots=webassembly

ただし,ボイラープレートを忘れるとメモリリークの危険があったり,デメリットもあります.

状態管理ライブラリ

それらの面倒なことを考えずに,コンポーネント間で安全に状態を共有できるようにしたかったのと同時に,単一方向データフローかつイミュータブルな状態管理で,ReDo/UnDo対応,Redux Devtoolにも対応させたかったので,ライブラリを作成しました.

状態の扱いや考え方はReactの状態管理にインスパイアされています.

今回はそれの紹介をします.(⭐よろしくね!)

https://github.com/le-nn/memento

特徴

  • イミュータブル
  • データを単一方向に設計
  • Redux Devtool対応
  • ReDo/UnDo対応
  • すべてのStoreの状態は1つの状態ツリーに集約することができる

単一方向データフローにすることで、
データが一方向に流れるように設計されることで、
データの流れが予測可能になります.
またイミュータブルな状態として扱うことで,
状態の予期せぬ変化を防ぎ,
変化の予測およびデバッグやテストが容易になります。

その他ライブライリ

Reactでよく利用されているReduxのBlazor向けの実装のFluxorや,
その他実装はありますが,
FluxorはFluxやReduxのBlazor向け実装で厳密なルールでの利用前提なので,
今回は割愛します.

Fluxor
https://github.com/mrpmorris/Fluxor

変更通知を呼び出す方法

状態管理ライブラリなしでメモリ内コンテナに状態を保存する場合,
以下のBlazor公式ドキュメントのような方法があります.

https://learn.microsoft.com/ja-jp/aspnet/core/blazor/state-management?view=aspnetcore-7.0&pivots=webassembly#in-memory-state-container-service-wasm

ただしこの方法では以下のボイラープレートが必要です.
特にStateContainer.OnChange -= StateHasChanged;を忘れるとメモリリークします.
これをもっと楽に行うというのが今回紹介するライブラリです.

@implements IDisposable
@inject StateContainer StateContainer

@code{
    protected override void OnInitialized() {
        StateContainer.OnChange += StateHasChanged;
    }

    public void Dispose() {
        StateContainer.OnChange -= StateHasChanged;
    }
}

ライブラリの紹介とチュートリアル

ライブラリのリポジトリ
https://github.com/le-nn/memento

動作デモページ
https://le-nn.github.io/memento/

ドキュメント
https://github.com/le-nn/memento/tree/main/docs

サンプルプロジェクト
https://github.com/le-nn/memento/tree/main/samples/Memento.Sample.Blazor

詳しい使い方はドキュメントを参照してください.
状態を管理するサービスクラスを以後,Storeと呼びます.

インストール

dotnet add package Memento.Blazor

状態(State)を定義する

まずは管理したい状態(State)をC#のレコードとして定義しましょう,
状態はイミュータブルにする必要があります.
状態を変更するにはプロパティを直接変更せずに新しいインスタンスを作成します.
これを簡略化するためにC#のレコードとwith式を利用します.

State内のCollectionは変更しない前提なのでImmutableArrayかImmutableListが望ましいでしょう.
Listとかだと誤って変更してしまう可能性があります.

CounterStore.cs

public record AsyncCounterState {
    public int Count { get; init; } = 0;
    public ImmutableArray<int> History { get; init; } = ImmutableArray.Create<int>();
    public bool IsLoading { get; init; } = false;
}

このように省略しても大丈夫です

CounterStore.cs

public record CounterState2(
    int Count,
    ImmutableArray<int> History,
    bool IsLoading
);

Storeを定義する

非同期にカウントアップするStoreの場合は以下のようになります.
実際のユースケースではAPIの呼び出し処理をしたりすることが想定されます.

CounterStore.cs

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 });

詳しいチュートリアルは英語ですが以下に用意しました
https://github.com/le-nn/memento/blob/main/docs/BasicConcept.md

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を登録

_Imports.razor
@using Memento.Core
@using Memento.Blazor

App.razorにコンポーネントのライフサイクルに合わせた初期化を行うために <MementoInitializer />を追加

App.razor
<MementoInitializer />

<Router AppAssembly="@typeof(App).Assembly">
  ...
</Router>

Counter.razorを以下のように変更.
ポイントとしては@inherits ObserverComponentを忘れないことです.
これを忘れるとStoreのStateの変更を検知できません.

Counter.razor
@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が自動で更新されていることを確認することができます.

もちろんコンポーネント間での状態の変更検知は自動で行われます.
以下のコンポーネントを作成します.

CountUpButton.razor
@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の使い方は別記事にしました

https://zenn.dev/remrem/articles/0768982b3cdc92

Discussion