本記事は 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> } }
ReadSignal と WriteSignal を別の型として受け取る設計だ。「このコンポーネントは値を読むだけで、書き換えはしない」といった制約をコンパイル時に保証できる。読み取り専用と書き込み可能を型で分離している。
サーバー関数 — #[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つの重要なパターンがある。
on:submit=move |ev: leptos::ev::SubmitEvent|— イベントハンドラには明示的な型注釈が必要。Rust のクロージャは引数の型を推論するが、Leptos のマクロ経由では推論が効かないprop:value=move || cidr.get()— Signal を直接渡すのではなく、move ||クロージャで包む。これが Leptos 0.8 の要件で、Signal からリアクティブに値を取り出す正しい方法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_context → use_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 は動いている。
たぶん、この往復が終わらないこと自体が、答えなのだと思う。