Leptos:RustでフルスタックWebアプリ開発


Leptos:RustでフルスタックWebアプリ開発

Rustのエコシステムは、システムプログラミングからWebアプリケーション開発まで広がりを見せています。その中でも注目を集めているのが、Rustでフロントエンド開発を可能にする Leptos フレームワークです。

本記事では、Leptosの基本から実践的なフルスタックアプリケーション開発までを詳しく解説します。

Leptosとは

Leptosは、Rustで書かれたリアクティブなWebフレームワークです。React、Vue、Svelteなどのモダンフロントエンドフレームワークの概念をRustで実現しつつ、以下の特徴を持っています。

主な特徴

  • 細粒度リアクティビティ: Solidjsライクな効率的なリアクティブシステム
  • 型安全: Rustの強力な型システムによる堅牢性
  • ゼロコストSSR: サーバーサイドレンダリングとハイドレーションの最適化
  • フルスタック対応: サーバー関数により、バックエンドとの統合が容易
  • 小さいバンドルサイズ: 効率的なコンパイル結果
  • WebAssembly: ブラウザで直接Rustコードを実行

セットアップ

前提条件

# Rustのインストール(まだの場合)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# trunkのインストール(開発サーバー)
cargo install trunk

# WebAssemblyターゲットの追加
rustup target add wasm32-unknown-unknown

プロジェクト作成

# Leptosテンプレートから作成
cargo install cargo-generate
cargo generate --git https://github.com/leptos-rs/start

# または手動でCargo.tomlを作成
cargo new leptos-app
cd leptos-app

Cargo.toml設定

[package]
name = "leptos-app"
version = "0.1.0"
edition = "2021"

[dependencies]
leptos = { version = "0.6", features = ["csr"] }
console_error_panic_hook = "0.1"
wasm-bindgen = "0.2"

基本的なコンポーネント

Hello World

use leptos::*;

#[component]
fn App() -> impl IntoView {
    view! {
        <div>
            <h1>"Hello, Leptos!"</h1>
            <p>"Rustで作るWebアプリケーション"</p>
        </div>
    }
}

fn main() {
    console_error_panic_hook::set_once();
    mount_to_body(|| view! { <App/> })
}

Leptosのビュー構文は、JSXライクなマクロを使用します。view! マクロ内でHTMLライクな記法が可能です。

リアクティブな状態管理

use leptos::*;

#[component]
fn Counter() -> impl IntoView {
    // リアクティブなシグナルを作成
    let (count, set_count) = create_signal(0);

    view! {
        <div class="counter">
            <h2>"カウンター"</h2>
            <p>"現在の値: " {count}</p>
            <div class="buttons">
                <button on:click=move |_| {
                    set_count.update(|n| *n += 1);
                }>
                    "増やす"
                </button>
                <button on:click=move |_| {
                    set_count.update(|n| *n -= 1);
                }>
                    "減らす"
                </button>
                <button on:click=move |_| {
                    set_count.set(0);
                }>
                    "リセット"
                </button>
            </div>
        </div>
    }
}

create_signal は、リアクティブな状態を作成します。戻り値は読み取り用と更新用の関数のタプルです。

派生シグナル(Computed Values)

use leptos::*;

#[component]
fn TodoCounter() -> impl IntoView {
    let (todos, set_todos) = create_signal(vec![
        ("買い物", false),
        ("洗濯", true),
        ("掃除", false),
    ]);

    // 派生シグナル:未完了タスク数を自動計算
    let remaining = move || {
        todos.with(|todos| {
            todos.iter().filter(|(_, done)| !done).count()
        })
    };

    let total = move || todos.with(|todos| todos.len());

    view! {
        <div class="todo-counter">
            <h3>"タスク進捗"</h3>
            <p>"残り: " {remaining} " / 全体: " {total}</p>
            <div class="progress-bar">
                <div
                    class="progress"
                    style:width=move || {
                        let done = total() - remaining();
                        format!("{}%", (done * 100) / total().max(1))
                    }
                />
            </div>
        </div>
    }
}

エフェクトとリソース

create_effect

use leptos::*;

#[component]
fn EffectExample() -> impl IntoView {
    let (name, set_name) = create_signal(String::from("匿名"));

    // nameが変更されるたびに実行される
    create_effect(move |_| {
        log!("名前が変更されました: {}", name.get());
    });

    view! {
        <div>
            <input
                type="text"
                on:input=move |ev| {
                    set_name.set(event_target_value(&ev));
                }
                prop:value=name
            />
            <p>"こんにちは、" {name} "さん!"</p>
        </div>
    }
}

リソースとSuspense

非同期データの取得には create_resource を使用します。

use leptos::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
}

async fn fetch_user(id: u32) -> Result<User, String> {
    // 実際のAPIコール
    gloo_net::http::Request::get(&format!("/api/users/{}", id))
        .send()
        .await
        .map_err(|e| e.to_string())?
        .json()
        .await
        .map_err(|e| e.to_string())
}

#[component]
fn UserProfile() -> impl IntoView {
    let (user_id, set_user_id) = create_signal(1u32);

    // user_idが変わるたびに再取得
    let user = create_resource(user_id, |id| async move {
        fetch_user(id).await
    });

    view! {
        <div class="user-profile">
            <h2>"ユーザープロフィール"</h2>

            <input
                type="number"
                on:input=move |ev| {
                    if let Ok(id) = event_target_value(&ev).parse::<u32>() {
                        set_user_id.set(id);
                    }
                }
                prop:value=user_id
            />

            <Suspense fallback=move || view! { <p>"読み込み中..."</p> }>
                {move || user.get().map(|result| match result {
                    Ok(user) => view! {
                        <div class="user-card">
                            <h3>{&user.name}</h3>
                            <p>"Email: " {&user.email}</p>
                        </div>
                    }.into_view(),
                    Err(e) => view! {
                        <p class="error">"エラー: " {e}</p>
                    }.into_view(),
                })}
            </Suspense>
        </div>
    }
}

ルーティング

Leptosは leptos_router クレートで強力なルーティングを提供します。

基本的なルーティング

[dependencies]
leptos = { version = "0.6", features = ["csr"] }
leptos_router = { version = "0.6", features = ["csr"] }
use leptos::*;
use leptos_router::*;

#[component]
fn App() -> impl IntoView {
    view! {
        <Router>
            <nav>
                <A href="/">"ホーム"</A>
                <A href="/about">"About"</A>
                <A href="/users">"ユーザー"</A>
            </nav>

            <main>
                <Routes>
                    <Route path="/" view=Home/>
                    <Route path="/about" view=About/>
                    <Route path="/users" view=Users/>
                    <Route path="/users/:id" view=UserDetail/>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn Home() -> impl IntoView {
    view! {
        <div>
            <h1>"ホーム"</h1>
            <p>"Leptosアプリケーションへようこそ"</p>
        </div>
    }
}

#[component]
fn UserDetail() -> impl IntoView {
    let params = use_params_map();
    let id = move || {
        params.with(|p| p.get("id").cloned().unwrap_or_default())
    };

    view! {
        <div>
            <h2>"ユーザー詳細"</h2>
            <p>"ユーザーID: " {id}</p>
        </div>
    }
}

サーバーサイドレンダリング(SSR)

Leptosの真骨頂は、フルスタック開発のサポートです。

サーバー関数

use leptos::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Post {
    id: u32,
    title: String,
    content: String,
}

// サーバーサイドでのみ実行される関数
#[server(GetPosts, "/api")]
pub async fn get_posts() -> Result<Vec<Post>, ServerFnError> {
    // データベースへのアクセスなど
    use sqlx::PgPool;

    let pool = use_context::<PgPool>()
        .ok_or_else(|| ServerFnError::ServerError("DB接続なし".into()))?;

    let posts = sqlx::query_as!(
        Post,
        "SELECT id, title, content FROM posts ORDER BY id DESC"
    )
    .fetch_all(&pool)
    .await
    .map_err(|e| ServerFnError::ServerError(e.to_string()))?;

    Ok(posts)
}

// クライアント側コンポーネント
#[component]
fn PostList() -> impl IntoView {
    let posts = create_resource(|| (), |_| async move {
        get_posts().await
    });

    view! {
        <div class="post-list">
            <h2>"記事一覧"</h2>

            <Suspense fallback=|| view! { <p>"読み込み中..."</p> }>
                {move || posts.get().map(|result| match result {
                    Ok(posts) => view! {
                        <ul>
                            {posts.into_iter()
                                .map(|post| view! {
                                    <li key={post.id}>
                                        <h3>{post.title}</h3>
                                        <p>{post.content}</p>
                                    </li>
                                })
                                .collect::<Vec<_>>()
                            }
                        </ul>
                    }.into_view(),
                    Err(e) => view! {
                        <p class="error">{e.to_string()}</p>
                    }.into_view(),
                })}
            </Suspense>
        </div>
    }
}

SSRモードでのCargo.toml

[dependencies]
leptos = { version = "0.6", features = ["ssr"] }
leptos_router = { version = "0.6", features = ["ssr"] }
leptos_axum = "0.6"
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls"] }

Axumサーバーのセットアップ

use axum::{
    routing::get,
    Router,
};
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};

#[tokio::main]
async fn main() {
    // Leptosの設定
    let conf = get_configuration(None).await.unwrap();
    let leptos_options = conf.leptos_options;
    let routes = generate_route_list(App);

    // Axumアプリケーション
    let app = Router::new()
        .leptos_routes(&leptos_options, routes, App)
        .fallback(leptos_axum::file_and_error_handler)
        .with_state(leptos_options);

    // サーバー起動
    let addr = "127.0.0.1:3000";
    println!("Listening on http://{}", addr);

    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app.into_make_service())
        .await
        .unwrap();
}

フォーム処理

アクション(Actions)

use leptos::*;

#[derive(Clone, Debug)]
struct FormData {
    name: String,
    email: String,
}

#[server(SubmitForm, "/api")]
pub async fn submit_form(data: FormData) -> Result<String, ServerFnError> {
    // サーバーサイドでの処理
    println!("フォーム送信: {:?}", data);

    // バリデーション
    if data.name.is_empty() {
        return Err(ServerFnError::ServerError("名前を入力してください".into()));
    }

    // データベース保存など
    Ok(format!("{}さん、登録ありがとうございます!", data.name))
}

#[component]
fn ContactForm() -> impl IntoView {
    let (name, set_name) = create_signal(String::new());
    let (email, set_email) = create_signal(String::new());

    // アクションの作成
    let submit_action = create_server_action::<SubmitForm>();

    let on_submit = move |ev: web_sys::SubmitEvent| {
        ev.prevent_default();

        let data = FormData {
            name: name.get(),
            email: email.get(),
        };

        submit_action.dispatch(data);
    };

    view! {
        <form on:submit=on_submit>
            <h2>"お問い合わせフォーム"</h2>

            <div>
                <label>"名前:"</label>
                <input
                    type="text"
                    on:input=move |ev| set_name.set(event_target_value(&ev))
                    prop:value=name
                />
            </div>

            <div>
                <label>"メール:"</label>
                <input
                    type="email"
                    on:input=move |ev| set_email.set(event_target_value(&ev))
                    prop:value=email
                />
            </div>

            <button type="submit" disabled=move || submit_action.pending().get()>
                {move || if submit_action.pending().get() {
                    "送信中..."
                } else {
                    "送信"
                }}
            </button>

            {move || submit_action.value().get().map(|result| match result {
                Ok(msg) => view! { <p class="success">{msg}</p> }.into_view(),
                Err(e) => view! { <p class="error">{e.to_string()}</p> }.into_view(),
            })}
        </form>
    }
}

パフォーマンス最適化

メモ化

use leptos::*;

#[component]
fn ExpensiveComponent(count: ReadSignal<i32>) -> impl IntoView {
    // 重い計算をメモ化
    let expensive_value = create_memo(move |_| {
        // ここでの計算はcountが変わった時だけ実行される
        (0..count.get()).fold(0, |acc, x| acc + x * x)
    });

    view! {
        <div>
            <p>"計算結果: " {expensive_value}</p>
        </div>
    }
}

仮想化リスト

大量のアイテムを扱う場合は仮想化を検討します。

use leptos::*;

#[component]
fn VirtualList() -> impl IntoView {
    let items: Vec<_> = (0..10000).map(|i| format!("Item {}", i)).collect();

    let (visible_start, set_visible_start) = create_signal(0);
    let visible_count = 20;

    let on_scroll = move |ev: web_sys::Event| {
        let target = event_target::<web_sys::HtmlElement>(&ev);
        let scroll_top = target.scroll_top();
        let item_height = 40;
        let new_start = (scroll_top / item_height).max(0) as usize;
        set_visible_start.set(new_start);
    };

    view! {
        <div
            class="virtual-list"
            style="height: 600px; overflow-y: auto;"
            on:scroll=on_scroll
        >
            <div style=move || format!("height: {}px", items.len() * 40)>
                <div style=move || format!("transform: translateY({}px)", visible_start.get() * 40)>
                    {move || {
                        let start = visible_start.get();
                        let end = (start + visible_count).min(items.len());
                        items[start..end]
                            .iter()
                            .enumerate()
                            .map(|(i, item)| view! {
                                <div key={start + i} style="height: 40px;">
                                    {item}
                                </div>
                            })
                            .collect::<Vec<_>>()
                    }}
                </div>
            </div>
        </div>
    }
}

実践例:TODOアプリ

完全なフルスタックTODOアプリを構築します。

use leptos::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct Todo {
    id: u32,
    text: String,
    completed: bool,
}

#[server(GetTodos, "/api")]
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
    // DBから取得(簡略化のため固定データ)
    Ok(vec![
        Todo { id: 1, text: "Leptosを学ぶ".into(), completed: false },
        Todo { id: 2, text: "Rustを極める".into(), completed: false },
    ])
}

#[server(AddTodo, "/api")]
pub async fn add_todo(text: String) -> Result<Todo, ServerFnError> {
    // DBに追加
    Ok(Todo {
        id: 3, // 実際はDBで生成
        text,
        completed: false,
    })
}

#[server(ToggleTodo, "/api")]
pub async fn toggle_todo(id: u32) -> Result<(), ServerFnError> {
    // DBを更新
    Ok(())
}

#[component]
fn TodoApp() -> impl IntoView {
    let (new_todo, set_new_todo) = create_signal(String::new());

    let todos = create_resource(|| (), |_| async move {
        get_todos().await
    });

    let add_action = create_server_action::<AddTodo>();
    let toggle_action = create_server_action::<ToggleTodo>();

    let on_submit = move |ev: web_sys::SubmitEvent| {
        ev.prevent_default();
        let text = new_todo.get();
        if !text.is_empty() {
            add_action.dispatch(AddTodo { text: text.clone() });
            set_new_todo.set(String::new());
        }
    };

    create_effect(move |_| {
        if add_action.value().get().is_some()
            || toggle_action.value().get().is_some() {
            todos.refetch();
        }
    });

    view! {
        <div class="todo-app">
            <h1>"Leptos TODO"</h1>

            <form on:submit=on_submit>
                <input
                    type="text"
                    placeholder="新しいタスクを追加"
                    on:input=move |ev| set_new_todo.set(event_target_value(&ev))
                    prop:value=new_todo
                />
                <button type="submit">"追加"</button>
            </form>

            <Suspense fallback=|| view! { <p>"読み込み中..."</p> }>
                {move || todos.get().map(|result| match result {
                    Ok(todos) => view! {
                        <ul class="todo-list">
                            {todos.into_iter()
                                .map(|todo| {
                                    let id = todo.id;
                                    view! {
                                        <li
                                            key={id}
                                            class:completed=todo.completed
                                        >
                                            <input
                                                type="checkbox"
                                                checked=todo.completed
                                                on:change=move |_| {
                                                    toggle_action.dispatch(ToggleTodo { id });
                                                }
                                            />
                                            <span>{todo.text}</span>
                                        </li>
                                    }
                                })
                                .collect::<Vec<_>>()
                            }
                        </ul>
                    }.into_view(),
                    Err(e) => view! {
                        <p class="error">{e.to_string()}</p>
                    }.into_view(),
                })}
            </Suspense>
        </div>
    }
}

まとめ

Leptosは、Rustの強力な型システムとパフォーマンスを活かしながら、モダンなWebアプリケーション開発を可能にするフレームワークです。

Leptosの利点

  • 型安全性: コンパイル時にエラーを検出
  • パフォーマンス: WebAssemblyによる高速実行
  • フルスタック: サーバーとクライアントを一つの言語で統合
  • 細粒度リアクティビティ: 効率的なDOM更新
  • 優れたSSRサポート: SEOとパフォーマンスの両立

向いているユースケース

  • 型安全性が重要なアプリケーション
  • パフォーマンスが求められるWebアプリ
  • Rustのエコシステムを活用したい場合
  • フルスタックをRustで統一したいプロジェクト

Leptosはまだ発展途上のフレームワークですが、Rustの成長とともに今後さらに注目が集まることでしょう。既にRustに慣れている開発者はもちろん、新しい技術に挑戦したい方にもおすすめのフレームワークです。