じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

Leptos 0.8 でフルスタック Rust を始める

leptos.dev

github.com

本記事は Rust でフロントエンド開発をしたことがある、あるいはこれから試してみたいエンジニア を想定している。React や Next.js などの SPA/SSR フレームワークの経験があると読みやすい。

はじめに

cargo leptos serve を叩いた。ターミナルが止まる。数十秒。Vite なら瞬きの間に終わる。Rust のコンパイラは型を検査し、借用をチェックし、所有権の整合性を検証している。その間、手持ち無沙汰になる。コーヒーを取りに行くほどではない。かといって別の作業を始めるには短い。ただ画面を見ている。「これ、TypeScript で書いた方が速かったのでは」と思う。

コンパイルが通った。ブラウザに画面が表示される。動く。undefined のランタイムエラーもない。型の不一致もない。「動くはず」ではなく「コンパイラが動くことを保証した」という確信がある。さっきまでの後悔が消える。消えるが、次のコンパイルでまた戻ってくる。

この往復は SubnetMap を作り終えるまで続いた。最後まで決着はつかなかった。たぶん、つかないまま終わるのだと思う。先に言っておく。

SubnetMap は社内の IP アドレスを管理する IPAM(IP Address Management)ツールだ。企業のネットワーク管理者が「どのサブネットにどの IP が割り当てられているか」を把握するために使う。サブネットの CIDR 管理、IP アドレスのステータス追跡、VLAN 紐付け、DNS レコード管理、監査ログ、アラート。Leptos 0.8(Rust 製の Web フレームワーク)と Axum 0.8(Rust 製の HTTP サーバー)で、ネットワーク管理という PostgreSQL の型システムが真価を発揮するドメインで、Leptos のパターンがどこまで通用するかを検証したかった。正直に言うと、「検証したかった」というのは後づけかもしれない。ただ作りたかった。作って、コンパイルが通るたびに安心したかった。その安心が本物かどうかを、確かめたかったのだと思う。

プロジェクト構成と SSR/Hydration の仕組み

Leptos アプリは 1つの Rust クレート(パッケージ)から、サーバー用バイナリとブラウザ用 WASM の2つのビルド成果物 を生成する。

subnetmap/
├── Cargo.toml          # features: ssr / hydrate
├── src/
│   ├── main.rs         # サーバーバイナリ (feature = "ssr")
│   ├── lib.rs          # WASM ライブラリ (feature = "hydrate")
│   ├── app.rs          # ルートコンポーネント + ルーティング
│   ├── components/     # UI コンポーネント
│   ├── pages/          # ページコンポーネント
│   ├── server/         # サーバー関数・DB アクセス
│   ├── models/         # 共有データモデル
│   └── error.rs        # エラー型
├── migrations/         # SQLx マイグレーション(12ファイル)
├── style/              # Tailwind CSS
└── docker-compose.yml  # PostgreSQL 17

Rust の feature flags(条件付きコンパイルのスイッチ)で、サーバーとクライアントの依存を分離する。

[features]
ssr = [
    "dep:axum",
    "dep:tokio",
    "dep:leptos_axum",
    "dep:sqlx",
    "dep:tower",
    "dep:tower-http",
    "dep:dotenvy",
    "dep:ipnetwork",     # CIDR パース用
    "leptos/ssr",
    "leptos_meta/ssr",
    "leptos_router/ssr",
]
hydrate = [
    "leptos/hydrate",
    "leptos_meta/hydrate",
    "leptos_router/hydrate",
]

依存関係にドメインの色が出ている。ipnetwork は CIDR 表記(10.0.0.0/24)のパースとアドレス計算に使う。serde_json は両ターゲットで使うため SSR features ではなくトップレベルの依存として定義している — 監査ログの JSONB カラムへの記録と、クライアント側のデシリアライズの両方で必要になるためだ。

SSR (Server-Side Rendering): ユーザーがページを開くと、サーバー側で HTML を生成して送信する。ブラウザは受け取った HTML をそのまま表示するだけなので、初回表示が高速で SEO にも有利だ。

Hydration: 画面が表示された後、ブラウザが WASM(WebAssembly — ブラウザで動く高速なバイナリ形式)をロードし、表示済みの HTML に「ボタンを押したら何が起きるか」などのイベントハンドラを接続する。ページの再描画なしにインタラクティブになる。

クライアント側のエントリポイント (lib.rs) は以下のようになる。

#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
    console_error_panic_hook::set_once();
    leptos::mount::hydrate_body(App);
}

cargo-leptos がこの2つのビルドを自動管理する。cargo leptos serve 一発で SSR バイナリと WASM の両方がビルドされ、ホットリロード付きの開発サーバーが立ち上がる。

コンポーネントと view! マクロ

Leptos のコンポーネントは #[component] 属性付きの関数で定義する。React の JSX の代わりに view! マクロ(コンパイル時にコードを生成する Rust の仕組み)で UI を記述する。

SubnetMap のサブネットカードコンポーネントを見てみよう。1つのサブネット情報をカード形式で表示する部品だ。

#[component]
pub fn SubnetCard(subnet: Subnet) -> impl IntoView {
    let id = subnet.id.to_string();
    let href = format!("/subnets/{}", id);
    let name = subnet.name.clone();
    let cidr = subnet.cidr.clone();
    let short_id = id.chars().take(8).collect::<String>();
    let desc = subnet.description.clone();

    view! {
        <a href=href
           class="block bg-slate-800 border border-slate-700 rounded-xl p-5 hover:border-blue-500/50 transition-colors">
            <div class="flex justify-between items-start mb-3">
                <div>
                    <h3 class="text-white font-semibold text-lg">{name}</h3>
                    <p class="text-blue-400 font-mono text-sm mt-1">{cidr}</p>
                </div>
                <span class="text-xs text-slate-500 bg-slate-700/50 px-2 py-1 rounded">
                    {short_id}
                </span>
            </div>

            {desc.map(|d| view! {
                <p class="text-slate-400 text-sm mb-3 line-clamp-2">{d}</p>
            })}

            <UtilizationBar used=subnet.used_count total=subnet.total_addresses />
        </a>
    }
}

ここで注目すべきは、view! マクロに渡す前に、構造体のフィールドを clone してローカル変数に束縛している点だ。&subnet.name のような参照を直接 view! の中で使うと、Leptos 0.8 ではコンパイルエラーになる。view! マクロが生成する DOM ノードのイベントハンドラはコンポーネント関数が return した後もブラウザ上で生き続けるため、参照ではなく所有権を持つ値を要求する。Rust のライフタイムルール — 参照は借用元より長生きできない — が、フロントエンドの文脈でそのまま適用された結果だ。

Option<T> のハンドリングも Rust らしい。React なら {desc && <p>{desc}</p>} と書くところを、Rust では {desc.map(|d| view! { ... })} と書く。None なら何も描画されない。型安全な条件付きレンダリングだ。

リアクティブシステム — Signal

Leptos の状態管理は Signal ベースだ。React の useState に相当するが、コンポーネント全体ではなく Signal を参照している DOM ノードだけが更新される、より細粒度のリアクティビティを提供する。

以下は「現在のフィルタ条件」を保持する Signal の宣言だ。読み取り用と書き込み用が別々の変数として返される。

// IP アドレス一覧: フィルタ状態管理
let (status_filter, set_status_filter) = signal(String::new());

Signal の変更が自動的にデータ再取得をトリガーする。SubnetMap の IP アドレス一覧では、ユーザーがステータスフィルタボタンを切り替えると、対応するサーバー関数が自動で再呼び出しされる。

let ips = Resource::new(
    move || status_filter.get(),  // この Signal が変わるたびに再取得
    |filter| async move {
        let f = if filter.is_empty() { None } else { Some(filter) };
        list_ip_addresses(None, f, None, Some(100)).await
    },
);

「All」→「Assigned」→「Reserved」とフィルタボタンを切り替えるたびに、status_filter Signal が更新され、Resource が自動的にサーバー関数を再呼び出しする。Signal → Resource → UI という反応チェーンが型レベルで保証される。React の useEffect の依存配列を手動で管理する必要がない。

フィルタボタン自体もリアクティブだ。

#[component]
fn FilterButton(
    label: &'static str,
    value: &'static str,
    current: ReadSignal<String>,
    set: WriteSignal<String>,
) -> impl IntoView {
    let is_active = move || current.get() == value;

    view! {
        <button
            on:click=move |_| set.set(value.to_string())
            class=move || if is_active() {
                "px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-500 text-white"
            } else {
                "px-3 py-1.5 rounded-lg text-sm font-medium bg-slate-700 text-slate-300 hover:bg-slate-600"
            }>
            {label}
        </button>
    }
}

ReadSignalWriteSignal を別の型として受け取る設計だ。「このコンポーネントは値を読むだけで、書き換えはしない」といった制約をコンパイル時に保証できる。読み取り専用と書き込み可能を型で分離している。

サーバー関数 — #[server]

#[server] 属性を付けた関数は、サーバー上でのみ実行される。クライアント(ブラウザ)からは自動的に HTTP リクエストとして呼び出される。API エンドポイントの定義と実装が1つの関数に集約されるのが特徴だ。

以下は「新しいサブネットを作成する」サーバー関数だ。CIDR 表記のパース、利用可能アドレス数の計算、DB への保存、監査ログの記録をまとめて行う。

#[server]
pub async fn create_subnet(
    cidr: String,
    name: String,
    description: Option<String>,
    parent_id: Option<Uuid>,
) -> Result<Subnet, ServerFnError> {
    use super::db::pool;
    use ipnetwork::IpNetwork;

    let pool = pool()?;
    let network: IpNetwork = cidr
        .parse()
        .map_err(|e| ServerFnError::new(format!("Invalid CIDR: {e}")))?;

    let total_addresses = if network.prefix() >= 31 {
        2_i32.pow(32 - network.prefix() as u32)
    } else {
        2_i32.pow(32 - network.prefix() as u32) - 2
    };

    let id = Uuid::now_v7();
    let subnet: Subnet = sqlx::query_as(/* ... */)
        .fetch_one(&pool)
        .await
        .map_err(|e| ServerFnError::new(format!("DB error: {e}")))?;

    // 監査ログ
    sqlx::query!(
        "INSERT INTO audit_logs (id, entity_type, entity_id, action, new_value)
         VALUES ($1, 'subnet', $2, 'create', $3)",
        Uuid::now_v7(),
        id,
        serde_json::to_value(&subnet).ok()
    )
    .execute(&pool)
    .await
    .ok();

    Ok(subnet)
}

ネットワーク管理ならではのロジックがサーバー関数に凝縮されている。CIDR 表記(10.0.0.0/24 のようなネットワーク範囲の記法)を ipnetwork クレートでパースし、利用可能アドレス数を計算する。/31 以上のサブネットではネットワーク・ブロードキャストアドレスの除外が不要、というネットワークのドメイン知識がコードに刻まれている。変更内容は監査ログに JSONB(PostgreSQL の JSON 型)で記録する。#[server] マクロが、引数のシリアライズ、HTTP エンドポイントの生成、エラーハンドリングをすべて自動化してくれる。

非同期データ取得 — Resource と Suspense

Resource はサーバー関数の結果をリアクティブに管理するコンテナだ。Suspense コンポーネントで「データ取得中はローディング表示、完了したら本来の UI を表示」というパターンを宣言的に書ける。

以下はダッシュボード画面の例だ。統計情報、アラート、最近の変更ログの3つを並行して取得し、それぞれ独立にローディング状態を管理している。

// ダッシュボード
let stats = Resource::new(|| (), |_| get_dashboard_stats());
let alerts = Resource::new(|| (), |_| list_alerts(Some(true)));
let recent_logs = Resource::new(|| (), |_| list_audit_logs(None, None, None, Some(10)));

view! {
    <Suspense fallback=|| view! { <div>"Loading..."</div> }>
        {move || stats.get().map(|result| match result {
            Ok(s) => view! {
                <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
                    <StatCard label="Total Subnets" value=s.total_subnets.to_string() color="blue" />
                    <StatCard label="Total IPs" value=s.total_ips.to_string() color="green" />
                    <StatCard label="Active Alerts" value=s.active_alerts.to_string() color="red" />
                    <StatCard label="Avg Utilization" value=format!("{:.1}%", s.avg_utilization) color="yellow" />
                </div>
            }.into_any(),
            Err(e) => view! { <p class="text-red-400">{e.to_string()}</p> }.into_any(),
        })}
    </Suspense>
}

into_any() が必要な理由は、Rust の型システムが match の各アーム(分岐先)に同じ型を要求するためだ。view! マクロが生成する型は内容によって異なる — <div> を含むビューと <p> だけのビューは別の型になる。into_any() で型を消去して AnyView に統一することで、match のどちらの分岐からも返せるようになる。HTML なら「div が来るか p が来るかは実行時に決まる」のは当然だが、Rust はコンパイル時にすべての型が確定していなければならない。.into_any() による型消去は、この2つの世界観の妥協点であり、SubnetMap のコード全体を通じて頻出するパターンだ。

ダッシュボードでは3つの独立した Resource を同時にフェッチしている。統計、アラート、最近の変更 — それぞれが独立した Suspense でローディング表示される。1つの API コールが遅くても他の表示はブロックされない。

フォーム処理 — Action とイベントハンドラ

Leptos 0.8 のフォーム処理では、いくつかの Rust 特有の型注釈が必要になる。以下は「新しいサブネットを作成するフォーム」コンポーネントだ。CIDR、名前、説明の3つの入力を受け取り、送信時にコールバック関数を呼ぶ。

#[component]
fn SubnetForm<F>(on_submit: F) -> impl IntoView
where
    F: Fn(String, String, String) + 'static + Clone,
{
    let (cidr, set_cidr) = signal(String::new());
    let (name, set_name) = signal(String::new());
    let (desc, set_desc) = signal(String::new());

    view! {
        <form on:submit=move |ev: leptos::ev::SubmitEvent| {
            ev.prevent_default();
            let on_submit = on_submit.clone();
            on_submit(cidr.get(), name.get(), desc.get());
        } class="space-y-4">
            <div>
                <label class="block text-sm text-slate-400 mb-1">"CIDR"</label>
                <input type="text"
                    placeholder="192.168.1.0/24"
                    prop:value=move || cidr.get()
                    on:input=move |ev| set_cidr.set(event_target_value(&ev)) />
            </div>
            <button type="submit">"Create Subnet"</button>
        </form>
    }
}

3つの重要なパターンがある。

  1. on:submit=move |ev: leptos::ev::SubmitEvent| — イベントハンドラには明示的な型注釈が必要。Rust のクロージャは引数の型を推論するが、Leptos のマクロ経由では推論が効かない
  2. prop:value=move || cidr.get() — Signal を直接渡すのではなく、move || クロージャで包む。これが Leptos 0.8 の要件で、Signal からリアクティブに値を取り出す正しい方法
  3. on_submit.clone()move クロージャが on_submit を所有権ごとキャプチャするため、複数回呼び出す場合は事前に clone が必要

SubnetForm がジェネリック <F> で定義されているのも特徴的だ。コールバック関数の型をジェネリクスで受け取ることで、呼び出し側がどんなクロージャでも渡せる。React の onSubmit?: (data: FormData) => void に相当するが、Rust では Fn トレイト境界 + 'static + Clone という明示的な制約が付く。

ルーティング

SubnetMap のルーティング定義を見てみよう。

#[component]
pub fn App() -> impl IntoView {
    provide_meta_context();

    view! {
        <Stylesheet id="leptos" href="/pkg/subnetmap.css" />
        <Title text="SubnetMap" />

        <Router>
            <Layout>
                <Routes fallback=|| "Page not found.".into_view()>
                    <Route path=path!("/") view=pages::dashboard::DashboardPage />
                    <Route path=path!("/subnets") view=pages::subnet_list::SubnetListPage />
                    <Route path=path!("/subnets/:id") view=pages::subnet_detail::SubnetDetailPage />
                    <Route path=path!("/ips") view=pages::ip_list::IpListPage />
                    <Route path=path!("/ips/:id") view=pages::ip_detail::IpDetailPage />
                    <Route path=path!("/vlans") view=pages::vlan_list::VlanListPage />
                    <Route path=path!("/vlans/:id") view=pages::vlan_detail::VlanDetailPage />
                    <Route path=path!("/audit") view=pages::audit::AuditPage />
                    <Route path=path!("/search") view=pages::search::SearchPage />
                    <Route path=path!("/settings") view=pages::settings::SettingsPage />
                </Routes>
            </Layout>
        </Router>
    }
}

10 ルート。IPAM ツールとしてサブネット、IP アドレス、VLAN の一覧と詳細、監査ログ、グローバル検索、タグ設定を提供する。Layout コンポーネントが固定サイドバーナビゲーションを提供し、すべてのページに共通の UI フレームを適用する。

動的パラメータの取得は use_params_map() で行う。

let params = use_params_map();
let id = move || {
    params.read().get("id")
        .and_then(|id| Uuid::parse_str(&id).ok())
};

Axum 統合とコンテキスト共有

main.rs で Axum サーバーをセットアップし、DB 接続プール(PgPool)を Leptos のコンテキストに注入する。これにより、すべてのサーバー関数から DB 接続を取得できるようになる。

let pool = PgPoolOptions::new()
    .max_connections(5)
    .connect(&database_url)
    .await
    .expect("Failed to connect to database");

sqlx::migrate!()
    .run(&pool)
    .await
    .expect("Failed to run migrations");

let app = Router::new()
    .leptos_routes_with_context(
        &leptos_options,
        routes,
        {
            let pool = pool.clone();
            move || {
                leptos::context::provide_context(pool.clone());
            }
        },
        { /* shell */ },
    )
    .fallback(leptos_axum::file_and_error_handler(shell))
    .with_state(leptos_options);

SubnetMap は認証不要のため、セッション管理レイヤーなしのシンプルな構成だ。しかし provide_contextuse_context のパターンは Leptos アプリに共通する基盤になる。

// server/db.rs
pub fn pool() -> Result<PgPool, ServerFnError> {
    use_context::<PgPool>()
        .ok_or_else(|| ServerFnError::new("Database pool not found in context"))
}

すべてのサーバー関数がこの pool() 関数を通じて DB 接続を取得する。PgPool は内部で Arc を使った参照カウント方式のプールなので、pool.clone() は実際にはポインタのコピーにすぎず、Arc のラップは不要だ。leptos_routes_with_context のクロージャはリクエストごとに呼ばれ、そのリクエストのスコープ内で use_context::<PgPool>() が有効になる。

エラーハンドリング

thiserror クレートを使ったエラー型定義。アプリケーション全体で発生しうるエラーを enum(列挙型)で網羅する。

#[derive(Debug, Error, Clone)]
pub enum AppError {
    #[error("Not found")]
    NotFound,
    #[error("Bad request: {0}")]
    BadRequest(String),
    #[error("Internal server error: {0}")]
    InternalError(String),
}

サーバー関数では ServerFnError を使い、UI 側では Result のパターンマッチで表示を分岐する。Rust の Result 型は「エラーが起きうる」ことを型レベルで強制するため、エラーの握り潰しが起きにくい。

SubnetMap では特に、CIDR のバリデーションエラー("Invalid CIDR: invalid address")、存在しないエンティティへのアクセス("Subnet not found")、DB 接続エラーなど、ネットワーク管理ツール固有のエラーパターンが型安全に処理される。

スタイリング — Tailwind CSS

Tailwind CSS(ユーティリティファーストの CSS フレームワーク)を使用。.rs ファイル内の view! マクロに書いた CSS クラスをビルド時に抽出して CSS を生成する。tailwind.config.js でネットワーク管理に適したカラーパレットを定義する。

module.exports = {
  content: ["./src/**/*.rs"],
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        subnet: {
          blue: "#3b82f6",
          green: "#22c55e",
          yellow: "#eab308",
          red: "#ef4444",
        },
      },
    },
  },
};

content: ["./src/**/*.rs"] で Rust のファイルから Tailwind のクラスを抽出する。view! マクロの中に書いた Tailwind クラスが、ビルド時に CSS として出力される。

SubnetMap では利用率に応じた色分け(green → yellow → red)がダッシュボードやサブネットカードの随所に現れる。ステータスバッジの色は IpStatus::color_class() メソッドで管理しており、Rust の match が全バリアントの網羅をコンパイル時にチェックするため、新しいステータスを追加したときに色の定義を忘れるとコンパイルエラーになる。enum と Tailwind の連携により、UI のステータス表現が型安全に管理される。

おわりに

IPAM アプリという具体的なドメインを通じて、Leptos 0.8 のフルスタック Rust 開発の実践パターンを見てきた。プロジェクト構成、コンポーネント、Signal、サーバー関数、ルーティング、Axum 統合。フレームワークの骨格は汎用的で、ドメインの色は主にモデルとサーバー関数に現れる。

冒頭で cargo leptos serve を叩いてターミナルが止まった話を書いた。あの数十秒は、この記事を書いている今も変わっていない。コンパイルが通るたびに安心する。通らないたびに「TypeScript なら」と思う。その往復は終わらなかった。終わらないまま、SubnetMap は動いている。

たぶん、この往復が終わらないこと自体が、答えなのだと思う。