Leptos Fetch を使ってキャッシュする ( Rust x Leptos キャッシュ編 )
はじめに
Fairy Devices でソフトウェアエンジニアをやっている nope
です。
最近、新しいプロジェクトで leptos
という Rust のフロントエンドフレームワークを使ってクライアントサイドの実装をしています。
WebAssembly
ではいろんなことができるようになってきているので、映像やリアルタイム通信を扱う部分で Rust をうまく活用できないかな、という気持ちで採用しました。
まだ触り始めて2ヶ月ほどですが、開発を通じて得た知見や、便利な外部クレートの使い方など共有していきたいなと思って、記事を書き始めました。
まだまだ実装が未熟な部分もあると思うので、「こうした方がもっといいよ!」といったアドバイスがあれば、ぜひ気軽にコメントで教えてください。
今後の連載はこんな感じで考えています。
-
leptos-fetch
でキャッシュする編(この記事) -
leptos-use
に学ぶカスタムフック作成編 -
leptos-oidc
を使った認証実装編 -
wasm-bindgen
で JS ライブラリを使う編 - UI 操作編
本記事の概要
leptos
でバックエンドの API 等で何度も同じデータを読み込む際は、leptos-fetch
を使うと便利です。
React の SWR
のようなクエリキーによるキャッシュ管理と再検証機能を持っており、外部リソースの読み込みを効率的に管理できます。
記事中のコードの全体は GitHub に置いてあります。
また、Github Pages でデモを動かせるようにしています。
目的と対象
この記事では、leptos
を使ってAPIなどの外部リソースを読み込む際に、leptos-fetch
でどうやってキャッシュを実装するかを、簡単な実装例や実装時の小さな tips を用いて解説します。
- フロントエンドを Rust で書いてみたい(書いている)人
-
leptos
で API 等外部リソースへの読み込みのキャッシュについて興味がある人
今回の記事は、leptos
を CSR(SPA) で動かすことを前提にしています。
SSR の場合、また違ったやり方もあるかもしれませんが、今回は触れません。
また、前提となるライブラリのバージョンは以下の通りです。
-
Rust
:1.89.0
-
leptos
:0.8.6
-
leptos-fetch
:0.4.4
leptos
のリアクティブな仕組みについて
本当に軽くleptos
の根幹をなすのは、シグナルという仕組みです。
ざっくり言うと、シグナルはある値の状態の変化を追跡し、その値が更新されたときに、その値を使っている UI 部分を自動的に再レンダリングする働きをします。
今回の記事では、data.get()
のように、リアクティブな値(この場合は data
)をクロージャの中で呼び出しています。
例えば get()
メソッドで値にアクセスすると、leptos
のランタイムが「このコンポーネントはこのシグナルに依存している」と認識します。
その後 data
の値を update()
などで変えると、leptos
のランタイムが依存を認識しているので、コンポーネントを自動的に再レンダリングします。
詳しくは、公式ドキュメントを参照してください。
また、nogiro の書いた Zenn 記事 でも軽く使い方を紹介しているので、こちらも参考にしてください。
LocalResource
で外部リソースを読み込む
leptos
には、LocalResource
という非同期でデータを取得するための便利な機能があります。(ドキュメント)
次のコードのように書くと、LocalResource
に渡した処理が、マウント時に実行され、結果が data
に格納されます。取得されるまでは、data
は None
です。
Suspense
[1] コンポーネントを使うと、非同期で取得される data
の値が None
から Some
に変わるまで待機し、その間に代替となるフォールバックUIを表示することができます。データが取得されると、コンポーネントが再レンダリングされます。(ドキュメント)
(この記事の実装例では、get_api_data
関数を使う前提で話をします。この get_api_data
関数は、id を指定して呼び出すと、何かしらの API からデータを取ってくる関数です。)
use crate::api::get_api_data;
use leptos::prelude::*;
#[component]
pub fn FetchByLocalResource(
selected_id: RwSignal<String>,
fetch_count: WriteSignal<u32>,
) -> impl IntoView {
let data = LocalResource::new(move || {
// 呼び出し回数を見るためにカウントを更新
fetch_count.update(|count| *count += 1);
get_api_data(selected_id.get())
});
view! {
<Suspense fallback=|| {
view! { <p>"Loading..."</p> }
}>
<div>
<p>"Fetched Data"</p>
{move || {
data.get()
.map(|api_data| {
view! {
<div>
<p>"ID: " {api_data.id}</p>
<p>"Name: " {api_data.name}</p>
</div>
}
})
}}
</div>
</Suspense>
}
}
どんな感じになるかというと、以下のようになります。
この映像からもわかるように、FetchByLocalResource
がマウントされるたびに、LocalResource
内の処理が走り、API からデータを取得していることがわかります。
もちろん、常に最新のデータを表示したい場合はこれでOKですが、データがほとんど変わらないのに毎回同じリクエストを送るのは、ちょっともったいないですよね。
leptos-fetch
を導入してみる
では、leptos-fetch
を使うとどうなるのでしょうか。
leptos-fetch
は、leptos
の LocalResource
をベースに、キャッシュ及びバックグラウンドによる再取得機能を拡張してくれるクレートです。
以下がサンプル実装になります。
use crate::api::get_api_data;
use leptos::prelude::*;
use leptos_fetch::QueryClient;
#[component]
pub fn FetchByLeptosFetch(
selected_id: RwSignal<String>,
fetch_count: WriteSignal<u32>,
) -> impl IntoView {
let client: QueryClient = expect_context();
let data = client.local_resource(
move |key| {
fetch_count.update(|count| *count += 1);
get_api_data(key)
},
move || selected_id.get(), // クエリキー (keyer)
);
view! {
<Suspense fallback=|| {
view! { <p>"Loading..."</p> }
}>
<div>
<p>"Fetched Data"</p>
{move || {
data.get()
.map(|api_data| {
view! {
<div>
<p>"ID: " {api_data.id}</p>
<p>"Name: " {api_data.name}</p>
</div>
}
})
}}
</div>
</Suspense>
}
}
どんな感じになるかというと、以下のようになります。
マウントされるたびに get_api_data
が呼ばれていないことが確認できると思います。
また、selected_id
が変化した場合でも、同じ id
であれば get_api_data
は呼ばれません。
QueryClient
は、非同期にデータを取得する処理(以下、クエリ)のキャッシュを管理するための構造体です。
また、[QueryClient
] は クエリキー(keyer
) をベースにキャッシュを管理しています。
そのため、QueryClient
の local_resource()
に渡された keyer
が同じであれば、すでにキャッシュされたデータを使用し、再度クエリが実行されることはありません。
今回の例では、selected_id
の値を keyer
として使っています。
そのため、最初に id
を選んでリクエストが実行されると、その結果はキャッシュされます。
その後、同じ id
に戻ってきた場合や、コンポーネントが再マウントされた場合でも、キャッシュが使われるため、リクエストは抑制されます。
keyer
の設計は非常に重要なポイントですが、今回はシンプルに String
を使って、基本的なキャッシュの挙動を理解することに焦点を当てます。本来であれば、より複雑なキーを設計して、さまざまなキャッシュ戦略を試すことができます。
これは SWR
の key
に相当するものだと思ってもらえるとわかりやすいかもしれません。
オプション指定
leptos-fetch
では、QueryOptions
を使うことでクエリのオプションを指定できます。
指定できるオプションには、以下のようなものがあります。
-
stale_time
: キャッシュが古くなったとみなされるまでの時間。この時間が過ぎて、data
を読み込もうとすると、再取得を試みます。 -
gc_time
: 未使用のキャッシュを削除するまでの時間。 -
refetch_interval
: 定期的に再取得する間隔。
また、QueryScope
を使うことで、このオプションを適用するをクエリを限定させることができます。
デフォルトでは、QueryClient
に設定したオプションは、そのクライアント経由で実行されるすべてのクエリに適用されます。
しかし、特定のクエリだけ異なるオプションを適用したい場合がありますよね。
例えば、「ユーザー情報だけは常に最新の状態を保ちたいけど、商品リストは1分間キャッシュしていい」といったケースです。
以下がサンプルの実装です。
use leptos_fetch::{QueryClient, QueryOptions, QueryScope};
use std::time::Duration;
...
let options = QueryOptions::default()
.with_stale_time(Duration::from_secs(60))
.with_gc_time(Duration::from_secs(120))
// わかりやすいように、1秒ごとに再取得を試みる
.with_refetch_interval(Duration::from_secs(1));
let scope = QueryScope::new(move |key| {
fetch_count.update(|count| *count += 1);
get_api_data(key)
})
.with_options(options);
1秒間隔で再取得を試みるようになっているので、何もしていなくてもカウントが増加していることが確認できると思います。
refetch
させる
強制的に 時には、キャッシュされたデータを無視して、強制的に最新のデータを取得したい場合もありますよね。
leptos
の素の LocalResource
では、refetch
メソッドを使うことで、再読み込みするメソッドがあります。
一方で、leptos-fetch
の QueryClient
が返す LocalResource
は、クライアント内部でキャッシュされています。
stale_time
まで経過しないと、この refetch
メソッドを読んでもキャッシュを返すため再読み込みされません。
そこで、leptos-fetch
では、QueryClient
には特定のクエリを強制的に stale にする invalidate
メソッドが用意されています。
以下はサンプル実装です。
let scope_cloned = scope.clone();
let data = client.local_resource(scope_cloned, move || selected_id.get());
let refetch = move || {
// これだけだと再取得されない
data.refetch();
};
let force_refetch = move || {
let scope = scope.clone();
// 現在の `selected_id` の値を使って、クエリを強制的に stale にする
client.invalidate_query(scope, selected_id.get_untracked());
};
view! {
...
<div class="flex gap-x-2 py-2">
<button
class="btn"
on:click=move |_| {
refetch();
}
>
"Refetch"
</button>
<button
class="btn"
on:click=move |_| {
force_refetch();
}
>
"Force Refetch"
</button>
</div>
...
}
このように、invalidate_query
を使うことで、特定のクエリキーの特定のスコープのクエリの強制的にキャッシュを無効化し、再取得できます。
デバッグツール
leptos-fetch
には、キャッシュの状況をひと目でわかるようにするデバッグツールが組み込まれています。
devtools
フィーチャーを有効にし、QueryDevtools
をアプリケーションに追加するだけで、ブラウザの開発者ツールのような UI でキャッシュの状態を確認できます。
最近まで1つバグがあって、stale_time
を指定せずにいると、QueryDevtools
上ではうまく動かない場合がありました。
Issue を立てたらすぐ対応してくれたので v0.4.4
以上の leptos-fetch
を使うと問題なく利用できるようになっています。
LocalResource
のラップ構造体
Tips: 特定の API クライアントを使って、一貫した方法で API を呼び出したいケースは多いですよね。
また、エンドポイントに応じてキャッシュ戦略を変えたり、コンポーネント内でのライフタイムを変更したいといった要望が出てくると思います。
そうした場合に、leptos-fetch
の QueryScope
, leptos
の LocalResource
をよしなにラッパーを実装すると、より簡潔に記述できます。
サンプル実装(多いので折りたたんでます。)
use crate::api::ApiData;
use crate::api::{Client, ClientError};
use leptos::prelude::*;
use leptos_fetch::{QueryClient, QueryOptions, QueryScopeLocal};
use std::sync::Arc;
/// [`LocalResource`] をラップして、特定の API クライアントを使ってデータを取得するための構造体
pub struct WrapperLocalResource<O> {
/// [`LocalResource`] の本体
pub resource: LocalResource<Result<O, ClientError>>,
/// 強制的に再取得をトリガーするための [`leptos::reactive::actions::Action`]
refetch: Action<(), ()>,
}
impl<O> Clone for WrapperLocalResource<O> {
fn clone(&self) -> Self {
*self
}
}
// [`LocalResource`] や [`Signal`] を Copy してもコストはほとんどない
// 参考: <https://book.leptos.dev/appendix_life_cycle.html>
impl<O> Copy for WrapperLocalResource<O> {}
impl<O> WrapperLocalResource<O>
where
O: std::fmt::Debug + Clone + 'static,
{
pub fn new<F, Fu>(action_fn: F, query_options: QueryOptions, keyer: Signal<String>) -> Self
where
F: Fn(Client, String) -> Fu + 'static,
Fu: Future<Output = Result<O, ClientError>> + 'static,
{
let query_client: QueryClient = expect_context(); // `expect_context` は、QueryClient をコンテキストから取得
let action_fn = Arc::new(action_fn);
// `QueryScopeLocal` を使って、ローカルなスコープを作成
// `key` が与えられるので、client と key を使って action_fn を呼び出す
let query_scope = QueryScopeLocal::new(move |key| {
let client = Client::new();
action_fn(client, key)
})
.with_options(query_options);
let query_scope_clone = query_scope.clone();
// `QueryScopeLocal` が非 Send であるので、`Action::new_local` で定義
let refetch = Action::new_local(move |()| {
let query_scope = query_scope_cloned.clone();
let key = keyer.get_untracked();
query_client.invalidate_query(query_scope, key);
// 非同期処理を実行するために利用できるが今回は空のクロージャを使う
async move {}
});
WrapperLocalResource {
resource: query_client.local_resource(query_scope, move || keyer.get()),
refetch,
}
}
/// 強制的に再取得をトリガーするメソッド
pub fn force_refetch(&self) {
// Action を dispatch することで、クエリを stale にする
self.refetch.dispatch(());
}
}
/// 特定の API クライアントを使ってデータを取得する `WrapperLocalResource` を簡単に作成する関数
pub fn data_resource(id: Signal<String>) -> WrapperLocalResource<ApiData> {
WrapperLocalResource::new(
// API クライアントを使ってデータを取得する関数
// key が取得できるので、クライアントでの呼び出しに必要なものを指定するとここで `id.get_untracked()` せずに済む
move |client, key| async move { client.fetch_data(key).await },
QueryOptions::default(),
id,
)
}
実装のポイント
-
WrapperLocalResource::new()
を使って、 APIクライアントを実行する関数、キャッシュオプション、そしてクエリキーのシグナルを受け取ります。これにより、実装下部のdata_resource
のように、特定のAPI用のラッパーリソースを簡単に作成できます。 -
Action
を使って、Action
の実行時にクエリを stale にしています。任意のタイミングで dispatch することで、必要時に再取得されます。
最後にこのラッパーを使ったコンポーネントの例を紹介して終わりにしようと思います。
FetchByLeptosFetchV2
use crate::resource::data_resource; // 上で定義した WrapperLocalResource を簡単に作成する関数
use leptos::prelude::*;
use std::time::Duration;
#[component]
pub fn FetchByLeptosFetchV2(
selected_id: RwSignal<String>,
fetch_count: WriteSignal<u32>,
) -> impl IntoView {
// 関数を呼び出すことで、`WrapperLocalResource` が作成される
let data = data_resource(selected_id.into());
let force_refetch = move || {
data.force_refetch();
};
view! {
<Suspense fallback=|| {
view! { <p>"Loading..."</p> }
}>
<div>
<p>"Fetched Data"</p>
{move || {
data.resource
.get()
.map(|api_data| {
match api_data {
Ok(data) => {
view! {
<div>
<p>"ID: " {data.id}</p>
<p>"Name: " {data.name}</p>
</div>
}
.into_any()
}
Err(_) => {
view! { <p class="text-red-500">"Error"</p> }.into_any()
}
}
})
}}
<div class="flex gap-x-2 py-2">
<button
class="btn"
on:click=move |_| {
force_refetch();
}
>
"Force Refetch"
</button>
</div>
</div>
</Suspense>
}
}
どうでしょうか?少しスッキリしたかなと思います。
最後に
最後まで読んでくださりありがとうございました。
この記事としては、基本的な使い方やキャッシュの実装方法については記述できたかなと思います。
ただし、まだ私自身も leptos-fetch
を使い始めたばかりで、まだまだ改善の余地のある実装もあると思いますし、細かい部分を把握しきれていないです。
leptos-fetch
の QueryClient
には、ここで紹介した以外にも多様な API が実装されています。
例えば、以下のようなものがあります。
-
Subscriptions
という初期キャッシュの取得時を知らせるシグナルや再検証中の状態を知らせるシグナルなど、より細かい状態を見ることができます。 - Linked Invalidation という、複数のクエリを連携させてキャッシュを無効化する仕組みもあります。(ドキュメント)
ただし、SWR
のような ミューテーションと再検証 と比較すると、mutate
に相当する再検証を走らせる処理はこの記事で紹介した invalidate_query
のような API で実現はできますが、細かな再検証用の便利 API (グローバルミューテート、成功時、失敗時にコールバックの設定など)はまだ実装されていないようです。
また AI コーディングでは、バージョンを指定したりソースコードを参考にさせたりしても、古いあるいは全く異なる実装を生成してくる時があります。
結局、公式ドキュメントを見直すことになるケースも少なくないので注意が必要です。
これは単純に、Rust でフロントエンドを記述する際の学習データが少ないんだろうと思います。
フロントエンドでも Rust を使ってみたい、という方は leptos
と leptos-fetch
をぜひ試してみてください。
-
Suspense
内部でLocalResource
のようなシグナルを監視しているので、データを取得処理の間はfallback
の UI をレンダリングし、取得が完了した後はchildren
をレンダリングするような動きになっています。記事上部のリアクティブな仕組みの説明 ↩︎
Discussion