🦀

`Transition` と `let:` bind で Leptos の非同期処理が絡む UI をスッキリ書く

に公開

はじめに

Fairy Devices でソフトウェアエンジニアをやっている nope です。

実務で API からデータを取得して表示する画面を実装しているとき、LocalResource + Suspense (or Transition) の組み合わせを使うと決まってネストが深くなることを気になっていました。
Coding Agent が書いてくれる場面も増えましたが、コードレビューをするときに「Ok のときに何を表示するか」というメインの処理よりもエラーやローディングの分岐が先に目に入るのが気になっていました。
もっとスッキリ書けないかと調べているうちに let: という構文に出会い、仕組みを理解しながら自分のコードに取り込んでみたのが今回の記事を書くきっかけです。

本記事の概要

本記事では、leptosview! マクロが提供する let: bind 構文に焦点を当て、Transition と組み合わせた非同期処理が絡む UI の書き方を紹介します。

記事中のコードの全体は GitHub に置いてあります。
GitHub Pages も公開していますので、実際の動きを見たい方はそちらもぜひご覧ください。

記事の対象

  • leptos で非同期で取得したデータを表示する UI を実装している(したい)人
  • let: bind の仕組みを理解したい人

今回の記事は、leptos を CSR(SPA) モードで動かすことを前提としています。
SSR の場合、また違ったやり方もあるかもしれませんが、今回は触れません。

また、前提となるライブラリのバージョンは以下の通りです。

  • Rust: 1.95.0
  • leptos: 0.8.13

LocalResource で読み込んだデータを表示する際のネストの問題

leptos には、LocalResource という非同期でデータを取得するための便利な機能があります。(ドキュメント)

LocalResource に渡した処理はマウント時に実行され、結果はリアクティブに格納されます。実際に取得したデータを取り出す際は、get() 等を使用します。
ただし、取得中であれば None を返し、取得が完了した場合に Some(T) で値が取り出せます。
Suspense [1] コンポーネントを使うと、非同期で取得される data の値が None から Some に変わるまで待機し、その間にフォールバック UI を表示できます。(ドキュメント)

また、取得処理は Result を返すことも多いので、get() の結果をアンラップして、成功時と失敗時で表示を分ける必要もあります。
実際に書いてみると、次のようなコードになりがちです。

use leptos::prelude::*;

#[component]
pub fn UserProfile(id: ReadSignal<String>) -> impl IntoView {
    let data = LocalResource::new(move || fetch_user(id.get()));

    view! {
        <Suspense fallback=|| view! { <p>"Loading..."</p> }>
            {move || {
                data.get().map(|result| {
                    match result {
                        Ok(user) => view! {
                            <div>
                                <p>"ID: " {user.id.clone()}</p>
                                <p>"Name: " {user.name.clone()}</p>
                            </div>
                        }.into_any(),
                        Err(_) => view! { <p class="error">"Error"</p> }.into_any(),
                    }
                })
            }}
        </Suspense>
    }
}

データのアンラップ・エラーハンドリングから表示まですべてが move || クロージャの中に混在しており、ネストが深くなりがちです。

Suspense に似た Transition を使うと、初回ロード後の再読み込み中に画面全体がローディング表示に戻るのを防ぎ、より自然な UX を実現できます。(ドキュメント)
ただし、move || { data.get().map(...) } というネストのパターン自体は変わりません。

もちろん成功時だけを表示するなら、エラーハンドリングを省略して次のように書くこともできますが、ローディング中の状態やエラーの場合を無視している感は否めません。

view! {
    <Transition fallback=|| view! { <p>"Loading..."</p> }>
        {move || {
            data.get().map(|user| {
                view! { <p>"Name: " {user.name.clone()}</p> }
            })
        }}
    </Transition>
}

他には、Suspend を使う方法もあります。
Suspend を使うと async move ブロックで data.await とすると LocalResource の値を直接 Result<T, E> として取得できるので、map() を呼ぶ必要がなくなり、ネストは1段浅くなります。

view! {
    <Suspense fallback=|| view! { <p>"Loading..."</p> }>
        {move || Suspend::new(async move {
            let res = data.await;
            match res {
                Ok(user) => view! {
                    <div>
                        <p>"ID: " {user.id.clone()}</p>
                        <p>"Name: " {user.name.clone()}</p>
                    </div>
                }.into_any(),
                Err(_) => view! { <p class="error">"Error"</p> }.into_any(),
            }
        })}
    </Suspense>
}

ネストは浅くなるものの、もっとシンプルに欲しい値だけを直接受け取る方法はないかと探していました。

Option<T>Some のときだけ値を直接受け取れる ShowLet というコンポーネントもあるよなあと思っていたりしました。
また、リスト系のコンポーネントをレンダリングするときに For というのを使っていて、こちらも let: という構文を見かけており、イテレーターで取り出した T をバインドしているのが気になっていました。

ShowLet というコンポーネント

leptos には Show という条件付き表示コンポーネントがありますが、Option<T>Some(_) のときだけ T を扱って表示する場合は少し不便です。

たとえば Option<String> を持つシグナルの値を表示しようとすると、こうなります。

let opt_name: RwSignal<Option<String>> = RwSignal::new(Some("Alice".to_string()));

view! {
    <Show when=move || opt_name.read().is_some()>
        <p>"Name: " {move || opt_name.get().unwrap()}</p>
    </Show>
}

when の条件チェックと値の取り出しが分離しているため、どちらも一旦値を取り出してから、is_some()unwrap() を呼ぶ必要があります。
ちなみに、条件部分については、read() としていますが、get() とは異なり、値をクローンせずに参照(厳密には ReadGuard を返すので、is_some() を呼ぶだけなら効率的です。

こういう場面で、ShowLet を使うと、こうなります。

let opt_name: RwSignal<Option<String>> = RwSignal::new(Some("Alice".to_string()));

view! {
    <ShowLet some=opt_name let:name>
        <p>"Name: " {name}</p>
    </ShowLet>
}

コード を見ると、some= が受け取るのは IntoOptionGetter<T, M> を実装した型です。
このトレイトはマーカー型 M によって実装の衝突を避けており、次の3種類を渡せます。

  • Fn() -> Option<T> を満たすクロージャ(FunctionMarker
  • Get<Value = Option<T>> を実装したシグナル型(SignalMarker。stable のみ。nightly では FunctionMarker 経由になる)
  • Clone + Send + Sync + 'staticOption<T> の静的な値(StaticMarker

RwSignal<Option<String>>Get<Value = Option<String>> を実装しているため、some=opt_name のようにシグナルを直接渡すことができます。
シグナルが Some(T) を持つとき、その中の値が let:name で直接受け取れます。
条件と値の取り出しが一体になっており、unwrap() も不要です。

ここで1つ気になるのが let:name という見慣れない構文です。
some=opt_name は普通の prop に見えますが、let:name はどこから来ているのでしょうか?

let: bind の仕組み

let:nameview! マクロが解釈する特殊な構文で、コンポーネントが処理した値を子要素の中で変数として受け取るためのものです。
実際にどう動いているかを、ミニマムなコンポーネントを実装して cargo expand で確認してみましょう。

let: を使えるコンポーネントを作る

まず、let: が使えるシンプルなコンポーネントを実装してみます。
「渡された値を3倍にして子要素に渡す」だけのコンポーネントです。

#[component]
pub fn MiniI32TripleLet<CnFn, View>(value: i32, children: CnFn) -> impl IntoView
where
    CnFn: Fn(i32) -> View + Send + Clone + 'static,
    View: IntoView + 'static,
{
    children(value * 3)
}

ポイントは children の型が Fn(i32) -> V、つまり 引数を取るクロージャ になっていることです。
通常の children: ChildrenFnFn() -> Fragment(引数なし)ですが、ここでは型パラメータ F を使って引数ありのシグネチャにしています。

このコンポーネントは次のように使えます。

view! {
    <MiniI32TripleLet value=42 let:x>
        <p>{x}</p>  // x = 126
    </MiniI32TripleLet>
}

コンポーネントが value * 3 の計算を担い、let:x でその結果を受け取れます。

cargo expand で展開してみる

let:x がどう変換されているか確認します。

cargo expand --package showcase-transition-let --target wasm32-unknown-unknown

マクロ展開後のコードを見ると、let:x が単なるクロージャの引数に化けていることがわかります。.children(...) とその引数の部分に注目してください。

fn __component_mini_i_32_triple_let_usage() -> impl IntoView {
    component_view(
        &MiniI32TripleLet,
        {
            let props = component_props_builder(&MiniI32TripleLet)
                .value(42)
                .children({
                    move |x| {                         // ← let:x がここに
                        p().child(                     //
                            IntoRender::into_render(x) // ← <p>{x}</p> がここに
                        )                              //
                    }
                })
                .build();
            props
        },
    )
}

let:x は消えており、代わりに .children({ move |x| { ... } }) が生成されています。
タグの中身がクロージャの body になり、let:x の識別子がその引数になっています。

マクロ内での変換箇所

この変換は leptos_macro クレートで行われています。
view! マクロ本体(lib.rs L272) がエントリポイントで、L291 から view/mod.rs に処理が移ります。
コンポーネントノードは component_builder.rs で組み立てられます。

ステップ 1 — let:x の識別子を収集(component_builder.rs L114

let items_to_bind = attrs.iter().filter_map(|attr| {
    if !is_attr_let(&attr.key) { return None; }
    // "let:x" → ident `x` を TokenStream として収集
    Some(quote! { x })
});

ステップ 2 — children をクロージャに変換(247行目)

if bindables.len() > 0 {
    // let: がある → move |x,| { children_body }
    quote! {
        .children({
            move |#(#bindables)*| #children
        })
    }
} else {
    // let: がない → move || { children_body }
    quote! {
        .children({
            ToChildren::to_children(move || #children)
        })
    }
}

items_to_bind が空でない場合(= let: がある場合)に children の body をクロージャで包み、引数として識別子を埋め込みます。

まとめると、let: bind の仕組みは次の2つのレイヤーに分かれています。

レイヤー 役割
view! マクロ let:x を検出して move |x| { body } に変換し、.children(...) に渡す
コンポーネント children: Fn(T) -> V を受け取り、処理済みの値を children(result) として渡す

ここまでわかると、ShowLetlet:name を受け取れる理由にも合点がいきます。
ShowLetchildrenFn(T) -> V になっており、Some のときだけ内側の値を引数として渡す実装になっているからです。

Transition と組み合わせる

let: bind の仕組みを理解したので、Transition にも同じことを適用してみましょう。
標準の TransitionchildrenFn() -> V(引数なし)であるため、let: を直接使えません。[2]

children: Fn(T) -> V を受け取る独自の TransitionLet コンポーネントを自作することで対応できます。

LocalResource はエラーを返す場合も多いので、Result<T, E> に対応できるよう error_view prop も受け取れるようにします。

use leptos::prelude::*;

#[component]
pub fn TransitionLet<Data, Error, CnFn, ErrFn, View, ErrView>(
    /// `Transition` 状態を監視される LocalResource 本体。今回はよくある `Result<Data, Error>` を想定。
    resource: LocalResource<Result<Data, Error>>,
    /// LocalResource の値が `Err` のときに呼び出される関数。引数は `Error` 型のエラー値。
    error_view: ErrFn,

    /// LocalResource の値が取得できていない場合に、表示されるフォールバック UI を返す関数。引数なし。
    #[prop(optional, into)] fallback: ViewFnOnce,

    /// `Transition` の `set_pending` を外部から受け取るためのオプションの prop。
    #[prop(optional, into)] set_pending: Option<SignalSetter<bool>>,

    children: CnFn,
) -> impl IntoView
where
    Data: Clone + Send + Sync + 'static,
    Error: Clone + Send + Sync + 'static,
    CnFn: Fn(Data) -> View + Send + Clone + 'static,
    ErrFn: Fn(Error) -> ErrView + Send + Clone + 'static,
    View: IntoView + 'static,
    ErrView: IntoView + 'static,
{
    // `set_pending` が渡されない場合は、ローカルで管理するためのシグナルを用意する。
    // `optional` な prop なので、呼び出しでは必ず渡す必要がある、
    let pending_setter = set_pending.unwrap_or_else(|| RwSignal::new(false).into());

    view! {
        <Transition fallback=fallback set_pending=pending_setter>
            {move || {
                resource
                    .get()
                    .map(|res| match res {
                        Ok(data) => children(data).into_any(),
                        Err(e) => error_view(e).into_any(),
                    })
            }}
        </Transition>
    }
}

where 句のトレイト境界について軽く補足しておきます。
DataErrorClone が必要なのは、resource.get() が値を clone して返すためです。
Send + Sync + 'staticleptos のリアクティブシステムが非同期タスクをスレッドをまたいで扱うため要求されます。
CnFn: CloneErrFn: Clonemove クロージャの中で毎回 clone() されるためです。

実装自体に大きな詰まりはありませんでしたが、型パラメータが6つ並ぶシグネチャは少し面倒でした。

では実際にこの TransitionLet を使ってみましょう。

view! {
    <TransitionLet
        resource=data
        fallback=|| view! { <p>"Loading..."</p> }
        error_view=|_| view! { <p class="error">"Error"</p> }
        let:user
    >
        <div>
            <p>"ID: " {user.id.clone()}</p>
            <p>"Name: " {user.name.clone()}</p>
        </div>
    </TransitionLet>
}

Transition のローディング挙動を維持しつつ、let:userOk の値だけを直接受け取れます。
最初に見た Suspense + move || { data.get().map(...) } のコードと比べると、かなりスッキリします。

TransitionLet にこの処理を集約することで、次のようなメリットがあります。

  • ロード中・エラー時の処理をコンポーネント内にカプセル化でき、呼び出し側は Ok のときのビューだけに集中できる
  • Ok の時に使用する実際の変数も map() あるいは match を避けることができてネストを防げる
  • fallbackerror_view の実装を複数箇所で再利用できる

補足: set_pending というオプションの prop

Transition はデータ再取得中も古い UI を表示し続けますが、その間読み込み中であることを別の場所で表示したいケースがあります。
そういったときに set_pending[3] が役立ちます。

同じように TransitionLet では Option<SignalSetter<bool>> として受け取り、省略可能にしています。

let (is_loading, set_is_loading) = signal(false);

view! {
    <Show when=move || is_loading.get()>
        <p>"読み込み中..."</p>
    </Show>

    <TransitionLet
        resource=data
        set_pending=set_is_loading  // Transition の pending 状態を外部シグナルに繋ぐ
        fallback=|| view! { <p>"Loading..."</p> }
        error_view=|_| view! { <p class="error">"Error"</p> }
        let:user
    >
        <div>
            <p>"Name: " {user.name.clone()}</p>
        </div>
    </TransitionLet>
}

ここで pending_setter という中間変数を置いています。

実は、Transitionset_pending#[prop(optional)] で宣言されています。
この属性があることで、呼び出し側は set_pending を省略できるようになっています(省略した場合は None が渡される)。
一方で、渡す場合においては、SignalSetter<bool> でなければなりません。
つまり、TransitionLet が受け取った Option<SignalSetter<bool>> をそのまま渡すことができません。
そのため、unwrap_or_else でダミーのシグナルを用意し、常に具体的な SignalSetter<bool>Transition に渡しています。

leptos の Issue #4124 で知ったのですが、#[prop(optional_no_strip)] という属性を使うと、呼び出し側も Option<T> のまま渡せるようになります。

Transition がこれを使っていれば pending_setter の中間変数は不要で、set_pending=set_pending と直接転送できます。
残念ながら現在の leptos では Transitionset_pending#[prop(optional)] のままなので、このような回避策が必要になります。

ちなみに、そうした中間表現をおかずに渡す方法もあって、view マクロで Transition を呼び出すのではなく Transition を関数として直接呼び出すを使うと、Option<SignalSetter<bool>> をそのまま渡せます。

#[component]
pub fn TransitionLet<Data, Error, CnFn, ErrFn, View, ErrView>(
    /// `Transition` 状態を監視される LocalResource 本体。今回はよくある `Result<Data, Error>` を想定。
    resource: LocalResource<Result<Data, Error>>,
    /// LocalResource の値が `Err` のときに呼び出される関数。引数は `Error` 型のエラー値。
    error_view: ErrFn,

    /// LocalResource の値が取得できていない場合に、表示されるフォールバック UI を返す関数。引数なし。
    #[prop(optional, into)] fallback: ViewFnOnce,

    /// `Transition` の `set_pending` を外部から受け取るためのオプションの prop。
    #[prop(optional, into)] set_pending: Option<SignalSetter<bool>>,

    children: CnFn,
) -> impl IntoView
where
    Data: Clone + Send + Sync + 'static,
    Error: Clone + Send + Sync + 'static,
    CnFn: Fn(Data) -> View + Send + Clone + 'static,
    ErrFn: Fn(Error) -> ErrView + Send + Clone + 'static,
    View: IntoView + 'static,
    ErrView: IntoView + 'static,
{
    Transition(TransitionProps {
        fallback,
        set_pending,
        children: ToChildren::to_children(move || {
            move || {
                resource.get().map(|res| match res {
                    Ok(data) => children(data).into_any(),
                    Err(e) => error_view(e).into_any(),
                })
            }
        }),
    })
}

補足: let()

今回実装した TransitionLet のように、children が単一の引数を受け取る場合(Fn(T) -> V)について説明してきましたが、children が複数の引数を受け取る場合(Fn(T, U) -> V)は、let: の代わりに let() という構文を使うことで同様のバインドが可能です。
たとえば ForEnumerate はインデックスと要素の2値を渡すため、let(idx, item) のように書けます。(ドキュメント)

最後に

最後まで読んでくださりありがとうございました。

let: bind は小さな構文ですが、仕組みを理解すると、children: Fn(T) -> V を持つコンポーネントなら自由に組み合わせられるという全体像が見えてきます。
ShowLet のような既存コンポーネントを活用するのはもちろん、今回の TransitionLet のように、自分が必要とする処理を担わせたコンポーネントを自作できます。

ぜひ let: (let()) bind を活用して、よりシンプルで読みやすいコードを書いてみてください!

脚注
  1. SuspenseLocalResource のようなシグナルを監視しており、データ取得中は fallback を、取得後は children をレンダリングします。leptos のシグナルの仕組みについては前回の記事を参照してください。 ↩︎

  2. children の型が Fn() -> V の場合、view! マクロが生成するクロージャは引数なしになります。let:x を書いてもバインドする引数がないため、コンパイルエラーになります。 ↩︎

  3. しかし、ドキュメントを読むに LocalResource のローディング状態を取り出す API がありません。
    気になって、leptosDiscussion #4692 でも質問を投げました。やっぱり現状の実装では取り出せる方法がないそうです。
    一方で、 Transitionset_pending という prop を使うと、Transition のローディング状態を外部から観測するためのリアクティブな prop を受け取れます。
    現時点では、Transitionset_pending が、LocalResource のローディング状態を取得するための回避策だそうです。 ↩︎

フェアリーデバイセズ公式

Discussion