状態管理ライブラリを激推しする記事
この記事はambr, Inc. Advent Calendar 2025の5日目の記事です。
ambrでUnityエンジニアをしているtanitaka-techと申します。
皆さんは状態管理ライブラリを使ったことはありますか?
一度使うと、もう元には戻れない。
今回はそんな魔力を持つ状態管理ライブラリについて紹介していきたいと思います。
状態管理ライブラリを使うと何が嬉しいのか
状態管理ライブラリを使うと、無駄な依存や冗長な定義を減らすことができます。
ECサイトの商品カートを思い浮かべてみてください。
商品カート一覧を更新した際には必ず、合計価格も更新しなければいけません。
今まではこう言う時、下記のような感じで対応するのが定石だったと思います。
public interface ICartItemRemove
{
void RemoveItem(ItemId id);
}
public interface ICartItemAdd
{
void AddItem(ItemId id);
}
public class CartItemRepository : ICartItemAdd, ICartItemRemove
{
private List<ItemId> _items;
private int _totalPrice;
public CartItemRepository(List<ItemId> items, int totalPrice)
{
_items = items;
_totalPrice = totalPrice;
}
public void AddItem(ItemId id)
{
_items.Add(id);
_totalPrice += id.Value;
}
public void RemoveItem(ItemId id)
{
_items.Remove(id);
_totalPrice -= id.Value;
}
}
ただ、毎回こんな風に丁寧に書くのは正直面倒…
interfaceも毎回大量に生まれるので、「この状態を更新するためにはこのinterfaceを使う」というようなルールも頑張って覚えていく必要があります。
状態管理ライブラリではこういった仕様を、少ないコードで分かりやすく表現できます。
例えばStateVariableという状態管理ライブラリを使うと、商品カートの合計価格は下記のようなコードで表現できます。
public readonly struct CartItemList
{
public List<ItemId> Value { get; }
public CartItemList(List<ItemId> value)
{
Value = value;
}
// 商品カート一覧のStateを作成
public static ObservableState<CartItemList> CreateState(List<ItemId> initItems)
{
return new ObservableState<CartItemList>(
new CartItemList(initItems)
);
}
}
public readonly struct TotalPrice
{
public int Value { get; }
public TotalPrice(int value)
{
Value = value;
}
// 他の状態に依存するStateを作成
public static DependencyState<TotalPrice> CreateState(
IStateObserver<CartItemList> cartItemListState,
IStateCollectionElementReader<ItemId, Item> itemStateCollectionElementReader
)
{
// 商品カート一覧の状態に応じて、合計金額を算出するStateを作成
return DependencyState<TotalPrice>.Create(
cartItemListState,
cartItemList =>
{
return new TotalPrice(cartItemList.Value
.Sum(itemId =>
{
var item = itemStateCollectionElementReader.ReadElement(itemId);
return item.Price;
}));
}
);
}
}
いかがでしょうか?TotalPrice型を見れば、「状態がどんな時に、どんな風に計算されるのか」がなんとなく分かるようになっていると思います。
また、状態の更新や購読などの操作はState型の方に既に定義されています。
状態を定義して任意のState型に入れるだけで必要な定義が揃うので、とても楽ちんです。
// DI用の関数
protected override void Construct(DependencyBinder binder)
{
var cartItemListState = CartItemList.CreateState(new List<ItemId>());
binder.Bind<IStateReader<CartItemList>>(cartItemListState); // Read用のinterface
binder.Bind<IStateSetter<CartItemList>>(cartItemListState); // Set用のinterface
binder.Bind<IStateObserver<CartItemList>>(cartItemListState); // Observe用のinterface
var totalPriceState = TotalPrice.CreateState(
cartItemListState,
itemStateCollectionElementReader
);
binder.Bind<IStateReader<TotalPrice>>(totalPriceState);
binder.Bind<IStateObserver<TotalPrice>>(totalPriceState);
// NOTE: 他の状態に依存しているStateにはSetterは無し
// binder.Bind<IStateSetter<TotalPrice>>(totalPriceState);
}
開発体験も非常に良好です。
例えば「状態の詳細のロジックは決まってないけど、とりあえずユーザークラスを先に作っておきたい」という時、型さえ定義すれば、最初からユーザークラスに本実装用のコードを書けます。
/// <summary>
/// 警告表示を出すかどうか
/// </summary>
public readonly struct NeedDisplayCaution
{
public bool Value { get; }
public NeedDisplayCaution(bool value)
{
Value = value;
}
// TODO: 警告表示を出すロジック
// 本実装時に更新予定
}
/// <summary>
/// 警告テキストオブジェクト(これで完成)
/// </summary>
public class CautionTextObjectPresenter
{
private CautionTextObjectView View { get; }
private IStateObserver<NeedDisplayCaution> NeedDisplayCautionStateObserver { get; }
public CautionTextObjectPresenter(CautionTextObjectView view, IStateObserver<NeedDisplayCaution> needDisplayCautionStateObserver)
{
View = view;
NeedDisplayCautionStateObserver = needDisplayCautionStateObserver;
}
public void StartLifeCycle()
{
View.InitView(new CautionTextObjectViewModel(
onIsVisibleChanged: NeedDisplayCautionStateObserver.Observe()
.Select(state => state.Value))
);
}
}
今までだとこういうことをする時、「どういうinterfaceで渡すか?」など色々考える必要があって、状態の方の定義を詰めてから、ユーザークラスの実装に着手しがちでした。
状態管理ライブラリを使えば、その辺の迷いをスキップできるので、開発していて非常に楽しいですね。
状態を定義するコツ
ここまで状態管理ライブラリの良さについて解説しました。
ただ少し懐疑的なのは、「コードが複雑なのは本当に状態管理の問題なのか?」ということです。
状態管理ライブラリを使わなくても、綺麗なコードを書く方は常に綺麗なコードを書く気がします。
実際のところコードが複雑な理由は、状態が複雑というより、仕様をコードに落とし込む段階で事故っているパターンが多いのではないでしょうか?
そこで、今回は状態を定義するコツについて6つの原則を考えてみました。
- 「か」:型にこだわる
- 「め」:命名にこだわる
- 「さ」:最小限のScopeで
- 「い」:依存関係をコードで可視化する
- 「こ」:コピーを作らない
- 「お」:置き場にこだわる
これに従えば誰でも最高な状態を作れるはずです。
頭文字をとって「かめさいこお」で覚えていきましょう🐢
「か」:型にこだわる
型を見ただけで、その状態にはどんな値が入るか分かるようにしましょう。
/// <summary>
/// 現在選択中のアイテム
/// </summary>
public readonly struct CurrentSelectItem
{
// NOTE: null許容を使うことで「選択中のアイテムが存在しない場合がある」ということを表現できる
public ItemId? Value { get; }
public CurrentSelectItem(ItemId? value)
{
Value = value;
}
public static ObservableState<CurrentSelectItem> CreateState(ItemId? value) => new ObservableState<CurrentSelectItem>(new CurrentSelectItem(value));
}
public enum ColorTheme
{
Light, Dark,
}
/// <summary>
/// 現在の選択中のテーマ
/// </summary>
public readonly struct CurrentColorTheme
{
public ColorTheme Value { get; }
public CurrentColorTheme(ColorTheme value)
{
Value = value;
}
public static ObservableState<CurrentColorTheme> CreateState(ColorTheme value) => new ObservableState<CurrentColorTheme>(new CurrentColorTheme(value));
}
/// <summary>
/// 月を表示するかどうか
/// </summary>
public readonly struct IsVisibleMoon
{
public bool Value { get; }
public IsVisibleMoon(bool value)
{
Value = value;
}
public static DependencyState<IsVisibleMoon> CreateState(IStateObserver<CurrentColorTheme> currentColorThemeStateObserver)
{
// ダークテーマの時は月を表示
return DependencyState<IsVisibleMoon>.Create(
currentColorThemeStateObserver,
colorThemeState => new IsVisibleMoon(colorThemeState.Value == ColorTheme.Dark)
);
}
}
<!-- markdownlint-enable -->
IDの表現にはプリミティブ型ではなく、ID毎に型を定義すると便利です。
public readonly struct ItemId : IEquatable<ItemId>
{
public int Value { get; }
public ItemId(int value)
{
Value = value;
}
// NOTE: ItemId関連の処理をここに集約できるので、非常に便利
public static ItemId None => new ItemId(0);
public bool Equals(ItemId other)
{
return Value == other.Value;
}
public override bool Equals(object? obj)
{
return obj is ItemId other && Equals(other);
}
public override int GetHashCode()
{
return Value;
}
public static bool operator ==(ItemId left, ItemId right)
{
return left.Equals(right);
}
public static bool operator !=(ItemId left, ItemId right)
{
return !left.Equals(right);
}
}
IDは色んな箇所で使われるので、ちゃんと型を切っておかないと色んなところで分かりづらい表現が生まれてしまいます。
面倒ですが、頑張る価値はあります。
Dictionary<ItemId, Item> itemMaster; // 最高
Dictionary<int, Item> itemMaster2; // きつい
ItemId itemId; // ItemIdだ
int itemId2; // ItemId?ほんとかな…
「め」:命名にこだわる
状態の名前にはこだわりましょう。
良い命名になっていると、ファイル分割した時にその機能がどういう状態を持っているのかが分かりやすいです。
そして状態が分かれば、その機能がどういう役割を持っているのか推測でき、コードを隅々まで読むコストを抑えられます。
- PlayVideoScene
- States
- CurrentVideo.cs
- CurrentRecommendedVideos.cs
- CurrentVideoComments.cs
// NOTE:
// 命名に「Current」や「State」を付けるかどうかは都度判断しますが
// 基本的に、他の型と区別できるなら付けない、そうでないなら付ける、という感じにしています。
「さ」:最小限のScopeで
「画面Aでは使う状態も、他の画面では使わない」ということがあります。
こういう状態は混乱しないように画面A以外では使えないようにしておきたいところです。
Unityの場合は、前回の記事でも紹介したDIコンテナのContext機能が使えます。
Contextを使うと好きな依存をグローバルに扱いつつも、ツリー構造でその公開範囲をScopingできるので、とても有用です。
- ProjectContext
- SceneAContext
- SceneBContext
上記のようにContextを切っておけば、SceneAContextに入れた依存はSceneBContextでは参照できないようにできます。
「い」:依存関係を可視化する
他の状態に依存している状態は、状態管理ライブラリを使って、依存関係を明示しましょう。
/// <summary>
/// ゲームオーバーかどうか
/// </summary>
public readonly struct IsGameOver
{
public bool Value { get; }
public IsGameOver(bool value)
{
Value = value;
}
public static DependencyState<IsGameOver> CreateState(
IStateObserver<CharacterHitPoint> characterHitPointObserver,
IStateObserver<RemainTime> remainTimeObserver
)
{
return DependencyState<IsGameOver>.Create(
characterHitPointObserver,
remainTimeObserver,
(characterHitPoint, remainTime) => new IsGameOver(
characterHitPoint.IsDead() ||
remainTime.Value <= 0
)
);
}
}
「こ」:コピーを作らない
なるべく値をコピーして使い回すような状態を作るのは避けましょう。
// 下記のようなマスターデータがある場合
public class MasterData
{
public Dictionary<ItemId, Item> ItemMaster { get; }
// 省略
}
// NG: ItemMasterの情報をコピーして保持
public struct CurrentCartItems_NG
{
public List<Item> Value { get; }
// 省略
}
// OK: ItemMasterを参照する前提
public struct CurrentCartItems_OK
{
public List<ItemId> Value { get; }
// 省略
}
値をコピーして使っていくと更新の時に各所でデータがずれたり、バグが起きた時の調査範囲が広がったりして後々の保守が大変になる可能性があります。
最適化などでどうしてもコピーが必要な場面もありますが、そういう必要なところ以外では極力避けた方が良いでしょう。
「お」:置き場にこだわる
プロジェクトの規模が大きくなってくると、状態を1つの場所でグローバルに管理するのはキツくなってくると思います。
// Statesディレクトリの中がどんどん増えて管理が大変に
- Project
- States
- UserPossessCharacters.cs
- CurrentSelectItem.cs
- CurrentDragItem.cs
- ...
こういう時は、画面単位やシーケンス単位などでディレクトリを分けて、必要な単位で状態を配置すると良いでしょう。
- Project
- CharacterScene
- States
- UserPossessCharacters.cs
- ItemSelectScene
- States
- CurrentSelectItem.cs
- CurrentDragItem.cs
終わりに
最後までお読みいただきありがとうございました。
今回の記事は色々迷走して書くのが大変だったのですが、なんとか形になってくれてほっとしています。
状態管理ライブラリの話をするのかと思ったら急に設計の話する人になってて恥ずかしいですね…今後精進していきたいと思います。
この記事が少しでも参考になれば幸いです。
Discussion