Leptos 2025 - RustでフルスタックWeb開発

2026.01.12

Leptosとは - Rust製フルスタックWebフレームワーク

Leptosは、Rustで構築されたフルスタックWebフレームワークです。SolidJSやSycamoreからインスピレーションを受けた**fine-grained reactivity(細粒度リアクティビティ)**を採用し、仮想DOMのオーバーヘッドなしに高パフォーマンスなWebアプリケーションを構築できます。

2025年現在、LeptosはGitHubで18,500以上のスターを獲得し、Rust Webフロントエンドフレームワークの中で最も注目を集めています。最新バージョンは0.8系で、WebSocket対応やエラーハンドリングの改善など、エンタープライズ利用を見据えた機能強化が続いています。

Leptosの核心 - Fine-Grained Reactivity

Signalsによるリアクティブシステム

Leptosの最大の特徴は、Signals(シグナル)を中心としたリアクティブシステムです。ReactやVueのような仮想DOMベースのフレームワークとは異なり、Leptosはリアクティブな値の変更時に実際のDOM要素を直接更新します。

use leptos::prelude::*;

#[component]
fn Counter() -> impl IntoView {
    // signal関数でリアクティブな状態を作成
    // タプルで(読み取り用, 書き込み用)シグナルを取得
    let (count, set_count) = signal(0);

    view! {
        <div class="counter">
            // countの値が変更されると、この<span>だけが更新される
            <span>"カウント: " {count}</span>
            <button on:click=move |_| set_count.update(|n| *n += 1)>
                "+1"
            </button>
            <button on:click=move |_| set_count.set(0)>
                "リセット"
            </button>
        </div>
    }
}

シグナルの基本操作

Leptosのシグナルは、読み取りと書き込みで異なるメソッドを提供します。

use leptos::prelude::*;

#[component]
fn SignalDemo() -> impl IntoView {
    let (value, set_value) = signal("Hello".to_string());

    // 読み取り操作
    // .get() - 値をクローンして取得(トラッキングあり)
    let current = value.get();

    // .with() - 参照で値にアクセス(クローンなし、トラッキングあり)
    value.with(|v| println!("現在の値: {}", v));

    // 書き込み操作
    // .set() - 新しい値で置き換え
    set_value.set("World".to_string());

    // .update() - 可変参照で値を更新
    set_value.update(|v| v.push_str("!"));

    view! {
        <p>{value}</p>
    }
}

Effectsによる副作用の管理

Effects(エフェクト)は、シグナルの変更に反応して副作用を実行するための仕組みです。依存関係は自動的にトラッキングされるため、明示的な依存配列は不要です。

use leptos::prelude::*;

#[component]
fn EffectDemo() -> impl IntoView {
    let (count, set_count) = signal(0);
    let (name, set_name) = signal("Leptos".to_string());

    // Effect::newでエフェクトを作成
    // countとnameの両方を参照しているため、どちらが変更されても再実行
    Effect::new(move |_| {
        // 依存関係は自動的にトラッキングされる
        let current_count = count.get();
        let current_name = name.get();

        // ローカルストレージへの保存などの副作用
        web_sys::console::log_1(
            &format!("{}: {}", current_name, current_count).into()
        );
    });

    view! {
        <div>
            <p>"Count: " {count}</p>
            <button on:click=move |_| set_count.update(|n| *n += 1)>
                "増加"
            </button>
        </div>
    }
}

Memoによる派生値の計算

Memoは、他のシグナルから派生する計算結果をキャッシュします。依存するシグナルが変更された時のみ再計算されます。

use leptos::prelude::*;

#[component]
fn MemoDemo() -> impl IntoView {
    let (items, set_items) = signal(vec![1, 2, 3, 4, 5]);

    // Memoで派生値を計算
    // itemsが変更された時のみ再計算される
    let total = Memo::new(move |_| {
        items.with(|items| items.iter().sum::<i32>())
    });

    let even_count = Memo::new(move |_| {
        items.with(|items| items.iter().filter(|n| *n % 2 == 0).count())
    });

    view! {
        <div>
            <p>"合計: " {total}</p>
            <p>"偶数の数: " {even_count}</p>
            <button on:click=move |_| set_items.update(|v| v.push(6))>
                "6を追加"
            </button>
        </div>
    }
}

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

SSRの仕組み

Leptosは**SSR(サーバーサイドレンダリング)をネイティブにサポートしています。サーバーでHTMLをレンダリングし、クライアントでWASMを使ってHydration(ハイドレーション)**を行うことで、SEOフレンドリーかつインタラクティブなアプリケーションを構築できます。

// main.rs(サーバーサイド - Axum使用)
use axum::{routing::get, Router};
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};

#[tokio::main]
async fn main() {
    let conf = get_configuration(None).unwrap();
    let routes = generate_route_list(App);

    let app = Router::new()
        .leptos_routes(&conf.leptos_options, routes, App)
        .fallback(leptos_axum::file_and_error_handler::<App>)
        .with_state(conf.leptos_options);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    axum::serve(listener, app.into_make_service())
        .await
        .unwrap();
}

Hydrationの設定

Cargo.tomlでfeatureフラグを使い分けることで、SSRとHydrationを制御します。

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

[dependencies]
leptos = { version = "0.8", features = ["nightly"] }
leptos_axum = { version = "0.8", optional = true }
leptos_router = { version = "0.8" }

[features]
# サーバーサイド用
ssr = ["leptos/ssr", "leptos_axum"]
# クライアントサイド(Hydration)用
hydrate = ["leptos/hydrate"]

ストリーミングHTML

LeptosはOut-of-Order Streamingをサポートしており、<Suspense>コンポーネントを使って非同期データの読み込みを効率的に処理できます。

use leptos::prelude::*;

#[component]
fn StreamingDemo() -> impl IntoView {
    // 非同期でデータを取得するResource
    let user_data = Resource::new(
        || (),
        |_| async move {
            // サーバーからユーザーデータを取得
            fetch_user_data().await
        }
    );

    view! {
        <div>
            <h1>"ユーザープロフィール"</h1>
            // Suspenseで非同期データの読み込みを待機
            // サーバーはfallbackを先に送信し、データ準備後に実際のコンテンツをストリーミング
            <Suspense fallback=move || view! { <p>"読み込み中..."</p> }>
                {move || user_data.get().map(|user| view! {
                    <div class="profile">
                        <p>"名前: " {user.name}</p>
                        <p>"メール: " {user.email}</p>
                    </div>
                })}
            </Suspense>
        </div>
    }
}

Islands Architecture - 軽量なインタラクティビティ

Islandsモードとは

Leptos 0.7以降では**Islands Architecture(アイランズアーキテクチャ)**をサポートしています。これは、サーバーレンダリングされたHTMLの「海」の中に、インタラクティブな「島」を配置するアプローチです。

// Islandsモードでは、#[island]マクロを使ったコンポーネントのみがハイドレートされる
use leptos::prelude::*;

// このコンポーネントはサーバーでのみレンダリング(静的HTML)
#[component]
fn StaticContent() -> impl IntoView {
    view! {
        <article>
            <h1>"記事タイトル"</h1>
            <p>"これは静的なコンテンツです。WASMは不要です。"</p>
            // Island内にインタラクティブなコンポーネントを配置
            <InteractiveCounter/>
        </article>
    }
}

// #[island]マクロでインタラクティブな島を定義
#[island]
fn InteractiveCounter() -> impl IntoView {
    let (count, set_count) = signal(0);

    view! {
        <div class="counter-island">
            <span>"カウント: " {count}</span>
            <button on:click=move |_| set_count.update(|n| *n += 1)>
                "クリック"
            </button>
        </div>
    }
}

バイナリサイズの大幅削減

Islandsモードの最大の利点はWASMバイナリサイズの削減です。典型的なアプリケーションで、通常モードの274KBから**24KB(約90%削減)**まで縮小できます。

# Cargo.toml - Islandsモードの有効化
[dependencies]
leptos = { version = "0.8", features = ["islands"] }

Server Functions - フルスタック開発の真髄

#[server]マクロ

LeptosのServer Functionsは、クライアントとサーバーの境界を透過的に越える関数を定義できます。APIエンドポイントを手動で設定する必要がなく、フルスタックコンポーネントを実現します。

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

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

// #[server]マクロでサーバー関数を定義
// クライアントからはHTTPリクエストとして呼び出される
#[server]
pub async fn get_todos() -> Result<Vec<Todo>, ServerFnError> {
    // この中のコードはサーバーでのみ実行される
    // データベースアクセスなどが可能
    use sqlx::PgPool;

    let pool = use_context::<PgPool>()
        .ok_or_else(|| ServerFnError::new("Database not available"))?;

    let todos = sqlx::query_as!(Todo, "SELECT id, title, completed FROM todos")
        .fetch_all(&pool)
        .await
        .map_err(|e| ServerFnError::new(e.to_string()))?;

    Ok(todos)
}

#[server]
pub async fn add_todo(title: String) -> Result<Todo, ServerFnError> {
    use sqlx::PgPool;

    let pool = use_context::<PgPool>()
        .ok_or_else(|| ServerFnError::new("Database not available"))?;

    let todo = sqlx::query_as!(
        Todo,
        "INSERT INTO todos (title, completed) VALUES ($1, false) RETURNING id, title, completed",
        title
    )
    .fetch_one(&pool)
    .await
    .map_err(|e| ServerFnError::new(e.to_string()))?;

    Ok(todo)
}

// コンポーネントからサーバー関数を呼び出し
#[component]
fn TodoList() -> impl IntoView {
    let todos = Resource::new(|| (), |_| get_todos());
    let add_todo_action = Action::new(|title: &String| {
        let title = title.clone();
        async move { add_todo(title).await }
    });

    view! {
        <div class="todo-app">
            <h1>"Todo リスト"</h1>
            <form on:submit=move |ev| {
                ev.prevent_default();
                let input = ev.target().unwrap()
                    .unchecked_ref::<web_sys::HtmlFormElement>()
                    .elements()
                    .named_item("title")
                    .unwrap()
                    .unchecked_ref::<web_sys::HtmlInputElement>()
                    .value();
                add_todo_action.dispatch(input);
            }>
                <input type="text" name="title" placeholder="新しいTodo"/>
                <button type="submit">"追加"</button>
            </form>
            <Suspense fallback=move || view! { <p>"読み込み中..."</p> }>
                {move || todos.get().map(|result| match result {
                    Ok(todos) => view! {
                        <ul>
                            {todos.into_iter().map(|todo| view! {
                                <li class:completed=todo.completed>
                                    {todo.title}
                                </li>
                            }).collect_view()}
                        </ul>
                    }.into_any(),
                    Err(e) => view! { <p>"エラー: " {e.to_string()}</p> }.into_any(),
                })}
            </Suspense>
        </div>
    }
}

カスタムエラー型(0.8新機能)

Leptos 0.8では、FromServerFnErrorトレイトを実装することでカスタムエラー型を使用できます。

use leptos::prelude::*;
use server_fn::codec::JsonEncoding;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AppError {
    ServerError(String),
    DatabaseError(String),
    ValidationError(String),
    NotFound,
}

impl FromServerFnError for AppError {
    type Encoder = JsonEncoding;

    fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
        AppError::ServerError(value.to_string())
    }
}

#[server]
pub async fn create_user(
    name: String,
    email: String,
) -> Result<User, AppError> {
    // バリデーション
    if name.is_empty() {
        return Err(AppError::ValidationError("名前は必須です".into()));
    }

    // データベース操作
    match insert_user(&name, &email).await {
        Ok(user) => Ok(user),
        Err(e) => Err(AppError::DatabaseError(e.to_string())),
    }
}

WebSocketサポート(0.8新機能)

Leptos 0.8では、Server FunctionsでWebSocketプロトコルを使用できるようになりました。

use futures::{SinkExt, StreamExt};
use server_fn::{codec::JsonEncoding, BoxedStream, ServerFnError, Websocket};

// WebSocketプロトコルを使用するサーバー関数
#[server(protocol = Websocket<JsonEncoding, JsonEncoding>)]
pub async fn chat_websocket(
    input: BoxedStream<ChatMessage, ServerFnError>,
) -> Result<BoxedStream<ChatMessage, ServerFnError>, ServerFnError> {
    use futures::channel::mpsc;

    let mut input = input;
    let (mut tx, rx) = mpsc::channel(16);

    tokio::spawn(async move {
        while let Some(msg) = input.next().await {
            if let Ok(msg) = msg {
                // メッセージを処理してブロードキャスト
                let response = process_chat_message(msg).await;
                let _ = tx.send(Ok(response)).await;
            }
        }
    });

    Ok(rx.into())
}

Rust + WebAssemblyの利点

型安全性

Rustの強力な型システムにより、コンパイル時にバグを検出できます。

use leptos::prelude::*;

// 型安全なPropsの定義
#[component]
fn UserCard(
    name: String,
    age: u32,
    #[prop(optional)] bio: Option<String>,
    #[prop(default = false)] is_admin: bool,
) -> impl IntoView {
    view! {
        <div class="user-card" class:admin=is_admin>
            <h2>{name}</h2>
            <p>"年齢: " {age}</p>
            {bio.map(|b| view! { <p class="bio">{b}</p> })}
        </div>
    }
}

// 使用時に型チェックが行われる
#[component]
fn App() -> impl IntoView {
    view! {
        // コンパイルエラー: ageにStringを渡せない
        // <UserCard name="Alice" age="30"/>

        // OK: 正しい型
        <UserCard name="Alice".to_string() age=30/>
        <UserCard
            name="Bob".to_string()
            age=25
            bio=Some("Rustacean".to_string())
            is_admin=true
        />
    }
}

パフォーマンス

Rust + WebAssemblyの組み合わせは、JavaScript比で8-10倍高速なパフォーマンスを実現します(特に計算集約型タスク)。

// 重い計算処理の例
#[component]
fn HeavyComputation() -> impl IntoView {
    let (result, set_result) = signal(None::<u64>);
    let (computing, set_computing) = signal(false);

    let compute = move |_| {
        set_computing.set(true);

        // WASMで実行される高速な計算
        spawn_local(async move {
            let fib_result = fibonacci(45); // Rustで高速計算
            set_result.set(Some(fib_result));
            set_computing.set(false);
        });
    };

    view! {
        <div>
            <button on:click=compute disabled=computing>
                {move || if computing.get() { "計算中..." } else { "フィボナッチ(45)を計算" }}
            </button>
            {move || result.get().map(|r| view! { <p>"結果: " {r}</p> })}
        </div>
    }
}

// Rustの最適化された計算
fn fibonacci(n: u64) -> u64 {
    if n <= 1 { return n; }
    let mut a = 0u64;
    let mut b = 1u64;
    for _ in 2..=n {
        let temp = a + b;
        a = b;
        b = temp;
    }
    b
}

バンドルサイズの最適化

WASMバイナリは適切な設定で大幅に圧縮できます。

# Cargo.toml - リリースプロファイルの最適化
[profile.release]
opt-level = 'z'     # サイズ最適化
lto = true          # Link Time Optimization
codegen-units = 1   # 単一コード生成ユニット
panic = 'abort'     # パニック時のアボート

[profile.release.package.leptos]
opt-level = 'z'

Yew・Dioxusとの比較

フレームワーク比較表

特徴LeptosYewDioxus
リアクティビティFine-grained仮想DOM仮想DOM
SSRサポートネイティブ限定的あり
Server Functions#[server]マクロなしなし
Islandsサポートなしなし
マルチプラットフォームWeb専用Web専用Web/Desktop/Mobile
バンドルサイズ
学習曲線React経験者に優しいReact/SwiftUI経験者に優しい

選択の指針

Leptosを選ぶべき場面:
- フルスタックWebアプリケーション
- SSRとストリーミングHTMLが必要
- 最小限のバンドルサイズを追求
- Fine-grainedリアクティビティを活用したい

Yewを選ぶべき場面:
- React経験者が多いチーム
- シンプルなSPA開発
- 成熟したエコシステムを重視

Dioxusを選ぶべき場面:
- クロスプラットフォーム開発(Web + Desktop + Mobile)
- 統一されたコードベースが必要
- ホットリロード重視の開発体験

2025年のLeptosエコシステム

主要ライブラリ

[dependencies]
# コア
leptos = "0.8"
leptos_router = "0.8"
leptos_axum = "0.8"          # Axum統合
leptos_actix = "0.8"         # Actix Web統合

# ユーティリティ
leptos-use = "0.15"          # React Hooksライクなユーティリティ
leptos-query = "0.6"         # TanStack Query風のデータフェッチ

# UIコンポーネント
leptos-shadcn-ui = "0.2"     # shadcn/ui風コンポーネント
thaw = "0.4"                  # UIコンポーネントライブラリ

プロジェクトセットアップ

# cargo-leptosのインストール
cargo install cargo-leptos

# 新規プロジェクト作成(Axumテンプレート)
cargo leptos new my-app --git https://github.com/leptos-rs/start-axum

# 開発サーバー起動
cd my-app
cargo leptos watch

# リリースビルド
cargo leptos build --release

ディレクトリ構造

my-leptos-app/
├── Cargo.toml
├── src/
│   ├── main.rs          # サーバーエントリポイント
│   ├── lib.rs           # 共有コード
│   ├── app.rs           # アプリケーションルート
│   ├── components/      # 再利用可能なコンポーネント
│   │   ├── mod.rs
│   │   ├── header.rs
│   │   └── footer.rs
│   ├── pages/           # ページコンポーネント
│   │   ├── mod.rs
│   │   ├── home.rs
│   │   └── about.rs
│   └── server/          # サーバー専用コード
│       ├── mod.rs
│       └── db.rs
├── style/
│   └── main.scss
└── public/
    └── favicon.ico

実践的なルーティング例

use leptos::prelude::*;
use leptos_router::components::*;
use leptos_router::path;

#[component]
fn App() -> impl IntoView {
    view! {
        <Router>
            <header>
                <nav>
                    <A href="/">"ホーム"</A>
                    <A href="/about">"About"</A>
                    <A href="/blog">"ブログ"</A>
                </nav>
            </header>
            <main>
                <Routes fallback=|| view! { <NotFound/> }>
                    <Route path=path!("/") view=HomePage/>
                    <Route path=path!("/about") view=AboutPage/>
                    <Route path=path!("/blog") view=BlogList/>
                    <Route path=path!("/blog/:id") view=BlogPost/>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn BlogPost() -> impl IntoView {
    // URLパラメータの取得
    let params = use_params_map();
    let id = move || params.get().get("id").unwrap_or_default();

    let post = Resource::new(
        id,
        |id| async move { fetch_blog_post(id).await }
    );

    view! {
        <Suspense fallback=move || view! { <p>"読み込み中..."</p> }>
            {move || post.get().map(|result| match result {
                Ok(post) => view! {
                    <article>
                        <h1>{post.title}</h1>
                        <div inner_html=post.content/>
                    </article>
                }.into_any(),
                Err(_) => view! { <NotFound/> }.into_any(),
            })}
        </Suspense>
    }
}

まとめ

Leptos は2025年、Rust製Webフレームワークの中で最もフルスタック開発に適した選択肢として確立されました。

Leptosの強み:

  • Fine-grained Reactivity: 仮想DOMなしの高効率な更新
  • Server Functions: クライアント・サーバー境界を透過的に越える
  • Islands Architecture: 必要な部分だけをインタラクティブに
  • 型安全性: Rustの型システムによるコンパイル時バグ検出
  • パフォーマンス: JavaScript比8-10倍高速(計算集約タスク)

Rust + WebAssemblyの組み合わせは、特にパフォーマンスと信頼性が重要なWebアプリケーションにおいて、従来のJavaScriptフレームワークに対する強力な代替手段となっています。学習コストは高いものの、長期的なメンテナンス性とパフォーマンスの観点から、エンタープライズでの採用が進んでいます。

参考リンク:

この技術を体系的に学びたいですか?

未来学では東証プライム上場企業のITエンジニアが24時間サポート。月額24,800円から、退会金0円のオンラインIT塾です。

LINEで無料相談する
← 一覧に戻る