MoonZoon フロントエンドの状態管理入門
これは Rust Advent Calendar 2023 23日目の記事です。
今回は最近の私のお気に入りの Rust 製 web フレームワークである MoonZoon の紹介をさせていただきます。
MoonZoon とは?
Rust のフルスタック web アプリケーションフレームワークです。 Seed のメンテナでもある @MartinKavik 氏がメインコミッタとなって開発しています。
MoonZoon はフルスタック web アプリケーションフレームワークとして求められる機能をいくつかのクレートに分けて提供しています。
- moon: バックエンド
- zoon: フロントエンド
- mzoon: ビルドツール、CLI
2023年12月時点では web フレームワーク全体としては開発途中であり、フロントエンド部分である zoon クレートがほぼ production ready、バックエンド部分の moon は actix-web をベースとしたプロトタイプが実装されている状況です。
バックエンドの moon の方は私はまだあまり触れられていないので、今回は production ready なフロントエンド部分である Zoon クレートを中心に見ていきます。
Zoon: フロントエンド部分のクレート
Zoon は futures-signals と dominator をベースに作られた 関数型リアクティブプログラミング (Functional Reactive Programming, FRP) のパラダイムを持つフロントエンドクレートです。同じ Rust 製フロントエンドフレームワークの Yew, Leptos, Dioxus, Sycamore などと比較すると、Zoon は従来のHTML/CSS/JavaScript スタックを極力 Rust の型システムで抽象化した API を提供しており、builder pattern で view 要素を構成していく点が特徴的に思います。またそれと同時に実行時速度とバンドルサイズの軽量化も重視して設計されているようです。
フロント側で画面描画やユーザーインタラクションに必要なデータをどのように管理するか、いわゆる 状態管理 はフロントエンド開発における主要な関心事の一つです。Zoon では futures-signals クレートが状態管理の API として中心的な役割を果たしているのですが、このクレートは非同期 Rust 上で FRP の概念を実現したものであるため、私がいざ本格的な SPA を作り始めてみた際には挙動に戸惑うことがよくありました。また残念なことに Zoon はまだ Docs.rs にドキュメントを publish していないので、ソースリポジトリの examples を探し回りながら実装方法を模索するしかないのも現状 MoonZoon に手を出しにくい点かなと思います。
Zoon の状態管理
最もシンプルな例として、ソースリポジトリにある docs より Zoon で書かれたカウンターアプリをコードを見てみましょう。
Mutable と Signal
use zoon::*;
// 1. 状態(データストア)
#[static_ref]
fn counter() -> &'static Mutable<i32> {
Mutable::new(0)
}
// 2. コマンド
fn increment() {
counter().update(|counter| counter + 1)
}
fn decrement() {
counter().update(|counter| counter - 1)
}
// 3. ビュー
fn root() -> impl Element {
Column::new()
.item(Button::new().label("-").on_press(decrement))
.item(Text::with_signal(counter().signal())) // <- [!]
.item(Button::new().label("+").on_press(increment))
}
// エントリーポイント
fn main() {
start_app("app", root);
}
Zoon での状態は Mutable<T>
という型に格納され、'static
ライフタイムを持つ参照を通じてアクセスします。この Mutable<T>
は Arc<RwLock<T>>
と似たような振る舞いをするメモリコンテナ型で、その最大の特徴は 内部データが変更されたときにその変更を通知する Signal を生成できる ことです。
Signal がどういうものなのか、(私を含めた)FRP に馴染みのない人にはピンときづらい概念かと思うので、futures-signals Docs のチュートリアル にある説明文を引用すると、
A Signal is an efficient zero-cost value which changes over time, and you can be efficiently notified when it changes.
筆者意訳: Signal とはゼロコストの 時間によって変化する値 で、(Mutable の)値が変化したときにその通知を受け取ることができる
というようなモノです。実際に上記コードの [!]
コメント部分では、状態に対して .signal()
メソッドを呼んで Signal を生成し、それを Text
要素に渡しています。こうすることで、状態の値が途中で変わっても常にその値を反映したビュー要素を作ることができます。
もし React の状態管理ライブラリの Recoil や Jotai を使ったことがある方であれば、Zoon における Mutable
と Signal
の関係は、Recoil における atom
と selector
の関係、Jotai における atom
と derived atom
の関係だと考えるとイメージしやすいかもしれません。雑に捉えるならば、Mutable
はその名の通り可変な値(状態)を入れておくもので、Signal
は Mutable
のデータから導出される値を受け取る場所と考えておけば良いでしょう。
グローバルな状態とローカルな状態
先程紹介したカウンターアプリのコードの例では、状態やその値を更新する関数を別々に定義していました。しかし Zoon では上記と同じ動作をするカウンターアプリを 1つのビュー要素(コンポーネント)にまとめて書くこともできます。
use zoon::{*, println};
fn root() -> impl Element {
let (counter, counter_signal) = Mutable::new_and_signal(0);
let on_press = move |step: i32| *counter.lock_mut() += step;
Column::new()
.item(Button::new().label("-").on_press(clone!((on_press) move || on_press(-1))))
.item_signal(counter_signal)
.item(Button::new().label("+").on_press(move || on_press(1)))
}
fn main() {
start_app("app", root);
}
上記のパターンB の例では、状態とそのシグナルのペアをビュー要素内で初期化しています。この場合、状態のスコープはビュー要素の関数内に閉じたものになるため、状態を更新する関数もまた同じスコープ内で定義する必要があります。ここでは更新の処理を on_press
という名前のクロージャにまとめ、Button
要素のコールバックに設定しています([-] ボタンと [+] ボタンで同じクロージャを使い回す都合上、片方には clone して渡しています)。
状態をグローバルとローカルのどちらに置くべきか? はケースバイケースですが、最も素朴な回答としては複数のビュー要素から使われたり操作されたりする状態(例:ユーザーのログイン状態)はグローバル、限られたビュー要素にのみ影響する状態(例:マウスカーソルの hover の状態)はローカルに置くのが良いと思います。この辺りは MoonZoon に限らずフロントのコンポーネント設計などの話になってくるので、ここでは敢えてこれ以上の深堀りはしません。
まとめ
MoonZoon のフロントエンド部分である Zoon について、簡単にコード例を見ながら書き方と状態管理の基本を紹介しました。
今回は思ったより執筆の時間が確保できず、MoonZoon の導入のホントに触りの部分しか言及できませんでした。MoonZoon は現状フレームワーク全体としては何かと未完成な状態ではあるものの、最近とあるプロダクトの案件にてフロント部分に Zoon を採用したところ満足できる品質の SPA を構築することができたため、十分実用に耐えうるフレームワークであると感じています。その案件を通して得られたより実践的な Zoon でのフロントエンド開発についての知見は、また別の機会にアウトプットできればと思います。
Discussion