🦀

Leptos の `<Show/>` コンポーネントを自分で実装しようとしたら地味に手強かった

2025/02/26に公開

こんにちは。Fairy Devices株式会社 となんらかの関わりがある nogiro (Twitter (現 Twitter): @nogiro_iota) です。

フロントエンドも Rust で書きたいなという欲求があるので、個人的に Leptos Book を読んでいます。コンポーネントにコンポーネントを渡す章 を読んで [1]前の方の 制御フローを定義する章 で出てきた <Show/> コンポーネント [2] を自分で実装できそうだなと思ったら、ちょっとだけ苦戦したので紹介します。

最終的なソースコード

コメントのある行の ViewFn が調べないと実装できなかった部分です。props を省略可能にするためには、コンポーネントにコンポーネントを渡す章 で出てきた render_prop を素朴に定義すると型まわりの扱いが大変でした。実はこれまでの章にオプショナルでジェネリックな props が利用できないことは書かれていましたが、今回は Leptos 内部でショートハンドとして使われていた ViewFn が好適でした。

main.rs
use leptos::prelude::*;

fn main() {
    // `<body>` タグの中で `App` コンポーネントを動かす
    leptos::mount::mount_to_body(App)
}

#[component]
fn App() -> impl IntoView {
    // シグナル `value` を用意
    // (「シグナル」は Leptos で UI の状態を扱う型)
    let (value, set_value) = signal(true);

    // UI の表示は view マクロの中に書く
    view! {
        // クリックするたびに `value` の bool 値を反転するボタン
        <button on:click=move |_| set_value.update(|value| *value = !*value)>"toggle"</button>

        // `value` が true のときは `true` というテキストノードを表示して、`value` が false のときは `false` というテキストノードを表示する
        <MyShow when=move || { *value.read() } fallback=|| view! { "false" }>
            "true"
        </MyShow>

        // `value` が true のときは `| true` というテキストノードを表示して、`value` が false のときは何も表示しない
        <MyShow when=move || { *value.read() }>"| true"</MyShow>
    }
}

#[component]
fn MyShow(
    // タグの中に書かれたものは children に渡される
    children: ChildrenFn,
    // 条件判定を行う関数を受け取る props (props は view マクロに HTML の属性のように書くもの)
    when: impl Fn() -> bool + Send + 'static,
    // 条件が false のときに UI の表示を行う関数 (`#[prop(optional)]` で省略可能にする)
    #[prop(optional, into)] fallback: ViewFn, // 省略可能な render_prop 向けの型が用意されていた
) -> impl IntoView {
    // Leptos では、UI の状態 (シグナルの値) が変更されたときに UI の表示を変更するためには関数を返す必要がある
    move || {
        if when() {
            // `children()` と `fallback.run()` は、IntoView を impl した異なる型を返すので、`into_any()` で型を合わせる
            children().into_any()
        } else {
            fallback.run().into_any()
        }
    }
}

なお、以降のソースコードブロックでは、以下の行はソース中にはあるけど例では省略しているものとします。

use leptos::prelude::*;

fn main() {
    leptos::mount::mount_to_body(App)
}

<Show/> コンポーネントとはなにか

<Show/> コンポーネントの前に、Leptos ってなんやねんと思う人もいるかも知れません。Leptos とは、Rust の Web フロントエンドフレームワークのクレートです。TypeScript ではなく Rust で、React のような UI 実装ができます。この記事中のソースコードでは Leptos についてもコメントで補足します。

<Show/> コンポーネントは UI の状態に応じて、表示を出し分けるためのコンポーネントです。ドキュメント を読むと、「子コンポーネント」 (タグで囲むみたいに渡すもの) と 「when」 と 「fallback」 という名前の props (HTML の属性みたいな書き方で渡すもの) を受け取るコンポーネントです。この 「when」 で指定した条件を満たしている場合は「子コンポーネント」を表示して、満たしていない場合は 「fallback」 で指定した render 関数の結果を表示します。

例えば、<Show/> を使って以下の実装を行うと、toggle ボタンをクリックするたびにテキストが切り換わります。

#[component]
fn App() -> impl IntoView {
    // シグナル `value` を用意
    // (「シグナル」は Leptos で UI の状態を扱う型)
    let (value, set_value) = signal(true);

    // UI の表示は view マクロの中に書く
    view! {
        // クリックするたびに `value` の bool 値を反転するボタン
        <button on:click=move |_| set_value.update(|value| *value = !*value)>"toggle"</button>

        // `value` が true のときは `true` というテキストノードを表示して、`value` が false のときは `false` というテキストノードを表示する
        <Show when=move || { *value.read() } fallback=|| view! { "false" }>
            "true"
        </Show>
    }
}

自分で実装する前に、コンポーネントを渡す方法の概説

コンポーネントにコンポーネントを渡す章 では、渡す方法として render_propchildren prop の 2 種類が紹介されています。

render_prop

コンポーネントをコンポーネントの props に渡す方法です。Leptos ではコンポーネントを view を返す関数として扱っていて、コンポーネントを利用するときに HTML の属性のように書いて渡す方法を props と言います。つまり、HTML の属性のような場所に関数を渡すやり方です。

例えば、以下の <RenderPropComponent/> コンポーネントを実装すると、render に渡した UI が <p> タグで囲まれます。最終的に <App/> では <p>aa</p> が描画されます。

#[component]
fn App() -> impl IntoView {
    view! {
        // `render` props に `aa` テキストノードを表示するクロージャーを渡す
        <RenderPropComponent render=|| view! { "aa" }/>
    }
}

#[component]
fn RenderPropComponent<F, IV>(render: F) -> impl IntoView
where
    F: Fn() -> IV + Send + 'static,
    IV: IntoView + 'static,
{
    // `render` props を `<p>` タグで囲んで表示する
    view! { <p>{move || render()}</p> }
}

children prop

Leptos でコンポーネントを使うとき view! {} マクロの中に HTML のタグのように書きます。そのタグ中に子コンポーネントや HTML 要素を書くと、外側のコンポーネントには children という名前の特殊な props に子コンポーネントや HTML 要素が渡されます。

上記の例と同様の、渡したものを <p> タグで囲む処理を行うコンポーネントは以下のようになります。

#[component]
fn App() -> impl IntoView {
    view! {
         // `children` props に `aa` テキストノードを渡す
        <ChildrenComponent>aa</ChildrenComponent>
    }
}

#[component]
fn ChildrenComponent(children: Children) -> impl IntoView {
    // `children` props を `<p>` タグで囲んで表示する
    view! { <p>{children()}</p> }
}

自分で <Show/> を実装してみる

つまり、以下のようなコンポーネントを作ると自分で <Show/> が実装できそうです。

#[component]
fn MyShow1<Fallback, FallbackView>(
    // 条件が true のときには、タグの中身を表示するため `children` を受け取る
    children: ChildrenFn,
    // 条件判定を行う関数を受け取る props
    when: impl Fn() -> bool + Send + 'static,
    // 条件が false のときに UI の表示を行う関数 (省略不可)
    fallback: Fallback,
) -> impl IntoView
where
    Fallback: Fn() -> FallbackView + Send + 'static,
    FallbackView: IntoView + 'static,
{
    // Leptos では、UI の状態 (シグナルの値など) が変更されたときに UI の表示を変更するためには関数を返す必要がある
    move || {
        if when() {
            // `children()` と `fallback()` は、IntoView を impl した異なる型を返すので、`into_any()` で型を合わせる
            children().into_any()
        } else {
            fallback().into_any()
        }
    }
}

しかし実は、<Show/> のドキュメントの fallback prop を見ると、fallback prop は省略可能になっています。そのため、引数の fallback: Fallback,Option<_> にする必要があります。

ただし、Leptos では オプショナルでジェネリックな Props [3] が利用できないため、ジェネリックを利用しないように Box<dyn Fn() -> ...> に変更する必要があります。

#[component]
fn MyShow2(
    children: ChildrenFn,
    when: impl Fn() -> bool + Send + 'static,
    // `#[prop(optional)]` にしてコンポーネント利用時に省略可能にして、`Option<_>` で受け取るようにする
    #[prop(optional)] fallback: Option<Box<dyn Fn() -> AnyView + Send + Sync>>,
) -> impl IntoView {
    // Leptos では、UI の状態 (シグナルの値) が変更されたときに UI の表示を変更するためには関数を返す必要がある
    move || {
        if when() {
            // `children()` と `fallback` の中身は、IntoView を impl した異なる型を返すので、`into_any()` で型を合わせる (`fallback` の中身は `AnyView` を返しているので不要)
            Some(children().into_any())
        } else {
            // None を返したときは何も表示しないので、`fallback` を省略した際は何も表示しない
            fallback.as_ref().map(|f| f())
        }
    }
}

しかし、この <MyShow2/> コンポーネントは、<Show/> コンポーネントと異なり利用する側で fallback に渡すクロージャーを Box<_> で囲んでやる必要があります。

fn App() -> impl IntoView {
    // 省略
    view! {
        // 省略
        <MyShow2
            when=move || { *value.read() }
            // `Box::new()` が必要
            fallback=Box::new(|| view! { "false" }.into_any())
        >
            "true"
        </MyShow2>
    }
}

<Show/> のソースコードを参考にしてみる

少し納得がいかないので <Show/> コンポーネントのソース を見てみましょう。そうすると、ViewFn というラッパーを利用していることが確認できます。Doc Comments に書かれている通り、#[prop(optional, into)] で利用しやすくするため、ViewFn には From trait と Default trait が impl されています。自分のコンポーネントで同様に ViewFn を利用すると、以下のような形になり、既存の <Show/> コンポーネントと同じ動きをするコンポーネントを実装できます。

#[component]
fn MyShow(
    // 条件が true のときには、タグの中身を表示するため `children` を受け取る
    children: ChildrenFn,
    // 条件判定を行う関数を受け取る props
    when: impl Fn() -> bool + Send + 'static,
    // 条件が false のときに UI の表示を行う関数
    // `ViewFn` には `Default` と `From` trait が impl されているので、`#[prop(optional, into)]` が利用可能
    #[prop(optional, into)] fallback: ViewFn,
) -> impl IntoView {
    move || {
        if when() {
            // `children()` と `fallback.run()` は、IntoView を impl した異なる型を返すので、`into_any()` で型を合わせる
            children().into_any()
        } else {
            fallback.run().into_any()
        }
    }
}

まとめ

ドキュメントを読んで課題にちょうど良さそうだなと思い、自分で <Show/> コンポーネントを作ってみましたが、前の方の章で書かれていた オプショナルでジェネリックな Props などをぜんぜん覚えていなかったので少し苦戦しました。

ちなみに、<Show/> コンポーネントのソースを読むと、実際はさらにメモ化されていて効率化が図られていたり、Either.into_any() を減らしたりしていて面白いです。

脚注
  1. ここで言う「読んだ」は「勝手訳を作った」という意味。 ↩︎

  2. 勝手訳版の <Show/>: https://nogiro.gitlab.io/leptos-book-unofficial-japanese-translation/view/06_control_flow.html#show-を使う ↩︎

  3. 勝手訳版の オプショナルでジェネリックな Props ↩︎

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

Discussion