本記事のコード
https://github.com/IshikawaHijiri/leptos_sample
目次
- はじめに
- Leptosとは
- この記事でやること
- 開発環境の準備(DockerでRust + Leptos)
- Leptosのベースプロジェクトを作る
- 開発サーバーを起動して画面を表示する
- コンポーネントを書いてみる
- Signals(React hooks like)で状態を扱う
- Effect と Memo を試す
- 動かして確認
- 補足:Leptos内部の仕組み
- まとめ
はじめに
Rustでフロントエンド開発ができると聞いて、
少し気になったことはありませんか。
WebAssembly(WASM)の登場・普及によって、
Rustのコードをブラウザ上で動かせるようになってきました。
その流れの中で生まれたのが、Rust製のフロントエンドフレームワークです。
この記事では、その中でも
Reactっぽい書き方でUIが書ける Leptos を使って、
- RustでWeb UIを書く
- 実際に画面を表示して動かす
ところまでを、できるだけシンプルに試してみます。
対象は次のような人です。
- web・フロントエンドは初心者、またはReactを少し知っている
- RustでWebアプリを作れるのか試してみたい
難しい理屈は後回しにして、
まずは「動いた!」という体験を目標に進めます。
Leptosとは
ひとことで言うと、Leptosは
- Rustで
- Reactっぽい書き方で
- モダンなWeb UI(バックエンドも含む)を作れる
フレームワークです。
これまでWebフロントエンドは、主にJavaScriptやTypeScriptで書かれてきました。
Leptosを使うと、ロジック・状態管理・UIの定義まで、すべてRustのコードで実装できます。
Rustの型安全や、コンパイル時にエラーを見つけられる強みを、そのままフロントエンドでも活かせます。
Reactに似た書き方
Leptosはコンポーネント指向で、
- 関数として画面を定義し
- 状態を持ち
- 値が変わると表示が変わる
という、Reactに近い考え方でUIを書きます。
HTMLのような見た目でUIを宣言的に書けるのも特徴です。
仮想DOMを使わない
LeptosはReactのような仮想DOMを使わず、
- 値ごとに依存関係を追跡し
- 変わった部分だけを直接更新する
細粒度リアクティブモデルを採用しています。
そのため、無駄な再計算が少なく、高速に画面を更新できます。
WASMでブラウザ上で動く
Leptosで書いたRustコードは、ビルドするとWASMになります。
ブラウザでは、
- 状態管理やロジックはWASMで実行
- 描画はDOMとして表示し、必要な部分だけJS経由で更新
という形で動きます。
フロントもバックエンドもRustで書ける
Leptosには Server Functions という仕組みがあり、
Rustの関数をそのままサーバー処理として定義し、フロントからasync関数のように呼び出せます。(Next.js Server Actionと似ている)
フロントとバックエンドで同じ言語・同じ型を共有できるのも大きな特徴です。
Leptosが目指していること
Leptosが目指しているのは、
- UIからロジックまでRustで統一
- 型安全で壊れにくいWebアプリ
- 無駄の少ない高速なUI更新
- フロントとバックの境界を意識しない開発体験
といった世界です。
Rustを中心に、フルスタックでWebアプリを作れることを目指したフレームワークだと言えます。
この記事でやること
この入門では、Leptosを使って、
- 開発環境を用意する
- ベースとなるプロジェクトを作る
- 開発サーバーを起動する
- サンプル画面を表示して動かす
- 簡単な状態管理(Signals)を試す
ところまでをやります。
ゴールはとてもシンプルで、
Rustで書いたコードが、
ブラウザで動いて、画面が表示される
ところを自分の環境で確認することです。
やらないこと
今回は入門なので、次のようなことは扱いません。
- デザインを作り込む
- 実用的なアプリを完成させる
- サーバーサイドの本格的な処理
- パフォーマンスチューニング
まずは、
Leptosの基本的な流れをつかむ
ことを優先します。
進め方
この記事では、
- 難しい理屈はできるだけ後回し
- コマンドを打って
- コードを貼って
- 実際に動かす
という流れで進めます。
途中でエラーが出ることもありますが、
その場合の対処もあわせて紹介します。
開発環境の準備(DockerでRust + Leptos)
ここでは、Leptosを動かすための環境をDockerで用意します。
ローカルにRustを直接入れても良いですが、今回は
- 環境差分でハマりにくい
- 後から消すのも簡単
という理由で、Dockerを使います。
Dockerが入っていない場合は、先にインストールしておいてください。
作業用フォルダを作る
まず、作業用のフォルダを適当に作ります。
mkdir leptos-test
cd leptos-test
GUIで作っていただいても大丈夫です。
Dockerfileを作る
次に、先ほど作ったフォルダに Dockerfile を作ります。
FROM rust:1-bookworm
RUN apt-get update && apt-get install -y \
curl ca-certificates pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add wasm32-unknown-unknown
RUN cargo install --locked trunk
RUN cargo install cargo-generate
WORKDIR /app
このDockerfileでは、
- Rust公式イメージをベースにする
- WASM向けのターゲットを追加する
- Leptosで使うツールを入れる
ということをしています。
ここで出てくる用語を簡単に言うと、
-
rustup
Rust本体やツールチェーンを管理する仕組み
Pythonでいう pyenv や conda のようなもの
-
cargo
ビルドや依存管理をまとめて行うRustの標準ツール
pip + poetry + build ツールが一体になったようなもの
と思っておけば大丈夫です。
docker-compose.yamlを作る
次に、同じフォルダに docker-compose.yaml を作ります。
services:
web:
build: .
working_dir: /app
volumes:
- ./:/app
ports:
- "3000:3000"
tty: true
stdin_open: true
これは、
- 今作ったDockerfileからイメージを作る
- ローカルのフォルダをコンテナ内の /app にマウントする
- ブラウザから 3000 番ポートでアクセスできるようにする
という設定です。
コンテナを起動する
準備ができたら、次のコマンドを実行します。
docker compose up -d --build
初回は少し時間がかかりますが、
Rustやツールが入ったコンテナが立ち上がります。
コンテナの中に入る
起動できたら、次のコマンドで中に入ります。
docker compose exec web bash
ここから先は、
Rustの入ったLinux環境の中で作業するイメージです。
Rust環境を確認する
本当にRustやツールが入っているか、確認してみます。
rustc -V
cargo -V
rustup -V
trunk -V
rustup target list --installed | grep wasm32-unknown-unknown
それぞれバージョンが表示され、
wasm32-unknown-unknown が出てくればOKです。(出てこない場合は再度rustupでインストールしてください。)
ここまでできたら、
Leptosを動かすための下準備は完了です。
Leptosのベースプロジェクトを作る
ここでは、Leptosの公式テンプレートを使って、
ベースとなるプロジェクトを作ります。
作業は、さきほど入ったDockerコンテナの中で行います。
cargo-generateでテンプレートから作成する
Leptosでは、テンプレートを元にプロジェクトを作るのが手早いです。
次のコマンドを実行します。
cargo generate --git https://github.com/leptos-rs/start-trunk --name leptos-intro
-
start-trunkは、CSR構成のシンプルなテンプレート -
leptos-introがプロジェクト名
このコマンドで、leptos-intro というフォルダが作られます。
Docker環境で出ることがあるエラー
Docker環境では、次のようなエラーが出ることがあります。
Error: could not determine the current user, please set $USER
これは、コンテナ内でUSER環境変数が設定されていないためです。
その場合は、次のコマンドで一時的に設定します。
export USER=root
もう一度 cargo generate を実行すれば、先に進めます。
生成されたファイルを確認する
生成後、フォルダの中身を見てみます。
ls leptos-intro
例えば、次のようなファイルが入っているはずです。
- Cargo.toml
- index.html
- src/
- style/
- Trunk.toml
今は、「Leptosのプロジェクトのひな形ができた」くらいの理解で大丈夫です。
Cargo.tomlを少し見る
leptos-intro/Cargo.toml を開くと、
プロジェクトの設定と依存関係が書かれています。
[package]
name ="leptos-intro"
version ="0.1.0"
edition ="2024"
[dependencies]
leptos = { version ="0.8", features = ["csr"] }
leptos_meta = { version ="0.8" }
leptos_router = { version ="0.8" }
ここは、
- プロジェクト名
- Rustのエディション
- 使うライブラリ
などを管理するファイルです。
Pythonでいうと、
- pyproject.toml
- requirements.txt
の役割をまとめたようなものだと思ってください。
開発サーバーを起動して画面を表示する
ここでは、Trunkというツールを使って開発サーバーを立ち上げます。
Trunkは、Rust + WASMのフロントエンド開発でよく使われるツールです。
プロジェクトに移動する
まず、作ったプロジェクトフォルダに移動します。
cd leptos-intro
trunk serveで開発サーバーを起動する
次のコマンドでサーバーを起動します。
trunk serve --address 0.0.0.0 --port 3000
-
-address 0.0.0.0は、コンテナの外(ホスト側)からアクセスできるようにするため -
-port 3000は、ブラウザで開くポート番号
起動すると、ビルドが走ってログが流れます。
エラーが出なければOKです。
ブラウザで開く
ホスト側(自分のPC)のブラウザで、次を開きます。
http://localhost:3000
Leptosのロゴとボタンが表示されれば成功です。
よくあるエラー:wasm32-unknown-unknownが入っていない
初回や環境によっては、次のようなエラーが出ることがあります。
- can't find crate for
core - the
wasm32-unknown-unknowntarget may not be installed
この場合は、WASMターゲットが入っていません。
次のコマンドで追加します。
rustup target add wasm32-unknown-unknown
その後、もう一度 trunk serve を実行すれば動くはずです。
コンポーネントを書いてみる
ここからは、表示されているサンプルコードを少し読みます。
Leptosでは、画面をコンポーネントという単位で書きます。
イメージとしては、
- 1つのコンポーネント = 画面の一部
- それを組み合わせてページを作る
という感じです。
Homeコンポーネントを見る
テンプレートでは、ホーム画面のコードが src/pages/home.rs にあります。
中身は長いですが、まずは下の部分だけ見ればOKです。
use crate::components::counter_btn::Button;
use leptos::prelude::*;
/// Default Home Page
#[component]
pub fn Home() -> impl IntoView {
view! {
<ErrorBoundary
fallback=|errors| {
view! {
<h1>"Uh oh! Something went wrong!"</h1>
<p>"Errors: "</p>
// Render a list of errors as strings - good for development purposes
<ul>
{move || {
errors
.get()
.into_iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
.collect_view()
}}
</ul>
}
}
>
<div class="container">
<picture>
<source
srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_pref_dark_RGB.svg"
media="(prefers-color-scheme: dark)"
/>
<img
src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg"
alt="Leptos Logo"
height="200"
width="400"
/>
</picture>
<h1>"Welcome to Leptos"</h1>
// ここ
<div class="buttons">
<Button />
<Button increment=5 />
</div>
</div>
</ErrorBoundary>
}
}
<div class="buttons">
<Button />
<Button increment=5 />
</div>
ここがポイントです。
-
<Button />は、Buttonコンポーネントを表示している -
<Button increment=5 />は、引数(props)を渡している
Rustなのに、HTMLやJSXみたいな書き方になっているのが分かると思います。
view!マクロがUIを作る
Leptosでは、コンポーネントは!viewマクロを返す関数として記述します。
#[component]
pubfnHome()->implIntoView {
view! {
// ここにUIを書く
}
}
Buttonコンポーネントを見る
Buttonコンポーネントは src/components/counter_btn.rs にあります。
use leptos::prelude::*;
/// A parameterized incrementing button
#[component]
pubfnButton(
#[prop(default = 1)] increment:i32,
)->implIntoView {
let (count, set_count) =signal(0);
view! {
<button
on:click=move |_| {
set_count.set(count.get() + increment)
}
>
"Click me: " {count}
</button>
}
}
ここでやっていることはシンプルです。
-
incrementという引数を受け取る(デフォルトは1) -
countという状態を持つ - クリックされたら
countを増やす - 画面に
countを表示する
Signalsで状態を扱う
Leptosでは、画面の状態を扱うためにsignal という仕組みを使います。
Reactを知っている人なら、useState に近いものと思って大丈夫です。
signalの基本
Buttonコンポーネントの中で、次のコードがありました。
let (count, set_count) =signal(0);
これは、
-
countが今の値 -
set_countが値を更新するためのハンドル
という意味です。
Reactで書くと、だいたいこんな感じです。
const [count, setCount] =useState(0);
値を読む
signalの値を読むときは、.get() を使います。
count.get()
Buttonの中では、クリック時にこうしています。
set_count.set(count.get() + increment)
今の値を取り出して、increment分だけ増やして、
新しい値としてセットしています。
値を表示する
view! の中では、signalをそのまま埋め込めます。
"Click me: " {count}
ここでは、
-
{count}の部分に今の値が表示される -
countが変わると、その部分だけ自動で更新される
という動きになります。
特別な再描画処理を書く必要はありません。
signalのポイント
signalのポイントは次の通りです。
- 状態を表す値を持てる
- 値が変わると表示が自動で変わる
- どの表示がどのsignalに依存しているかをLeptosが覚えている
そのため、
コンポーネント全体を描き直すのではなく、
必要な部分だけが更新される
という動きになります。
Leptos Signals Effect と Memo を試す
signalだけでも状態管理はできますが、
Leptosにはそれと組み合わせて使う仕組みとして、
- Effect
- Memo
があります。
どちらも、ReactのHooksを知っているとイメージしやすいです。
今回追加したコードは、src/pages/use_signals.rs にあります。
use crate::components::counter_btn::Button;
use leptos::prelude::*;
use leptos::logging;
/// Signals demo Home Page
#[component]
pub fn UseSignals() -> impl IntoView {
// useState 相当
let (count, set_count) = signal(0);
let (step, set_step) = signal(1);
// useMemo 相当: count * step を常に再計算
let double = Memo::new(move |_| count.get() * step.get());
// useEffect 相当: count が変わるたびにログ
Effect::new(move |_| {
logging::log!("count changed: {}", count.get());
});
view! {
<div class="container">
<h1>"Leptos Signals Demo"</h1>
<p>"Count: " {count}</p>
<p>"Step: " {step}</p>
<p>"Count x Step (memo): " {double}</p>
<div class="buttons">
<button on:click=move |_| set_count.update(|c| *c += 1)>
"+1"
</button>
<button on:click=move |_| set_count.update(|c| *c -= 1)>
"-1"
</button>
<button on:click=move |_| set_step.update(|s| *s += 1)>
"Step +1"
</button>
</div>
<hr/>
</div>
}
}
その他のファイルも合わせて編集します。
- ./src/pages/mod.rs
pub mod home;
pub mod use_signals; // 追加
pub mod not_found;
- ./src/lib.rs
- ルーティングに!pathマクロを追加
use leptos::prelude::*;
use leptos_meta::*;
use leptos_router::{components::*, path};
// Modules
mod components;
mod pages;
// Top-level pages
use crate::pages::home::Home;
use crate::pages::use_signals::UseSignals; // インポート
/// An app router which renders the homepage and handles 404's
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
<Html attr:lang="en" attr:dir="ltr" attr:data-theme="light" />
// sets the document title
<Title text="Welcome to Leptos CSR" />
// injects metadata in the <head> of the page
<Meta charset="UTF-8" />
<Meta name="viewport" content="width=device-width, initial-scale=1.0" />
<Router>
<Routes fallback=|| view! { NotFound }>
<Route path=path!("/") view=Home />
// ここ!
<Route path=path!("/use_signals") view=UseSignals /> // 追加
</Routes>
</Router>
}
}
http://localhost/use_signalsでこのように表示されます。
Effectは値の変化に反応する処理
Effectは、副作用を扱います。
- あるsignalが変わったときに
- 何か処理をしたい
というときに使います。
Effect::new(move |_| {
logging::log!("count changed: {}", count.get());
});
ここでは、
-
count.get()を読んでいる - そのため、countが変わるたびに
- ログを出す処理が実行される
という動きになります。
Reactでいうと、次に近いです。
useEffect(() => {
console.log(count);
}, [count]);
ポイントは、
- 依存配列を書かなくてよい
- 中で
.get()したsignalが、自動で依存関係になる
というところです。
Memoは計算結果を使い回す
Memoは、ある値から計算した結果を
- 他の場所で使いたい
- 元の値が変わったときだけ再計算したい
というときに使います。
コードはこちらです。
letdouble = Memo::new(move |_| count.get() * step.get());
これは、
- count と step を使って
- 掛け算した結果を double として持つ
という意味です。
Reactで書くと、こんな感じです。
const double =useMemo(() => count * step, [count, step]);
ここでも、
- 中で
.get()したsignalが依存関係になる - それらが変わったときだけ再計算される
という仕組みです。
Memoの値を表示する
Memoもsignalと同じように、view!の中でそのまま使えます。
<p>"Count x Step (memo): " {double}</p>
countやstepが変わると、
この部分だけが自動で更新されます。
EffectとMemoのまとめ
ここまでの話をまとめると、
-
signal
状態そのものを持つ
-
Effect
signalの変化に反応して処理をする
-
Memo
signalから計算した結果を持つ
という役割になります。
どれも、
-
.get()したsignalが依存関係になる - 明示的な依存配列は不要
というのがLeptosらしいポイントです。
補足:Leptos内部の仕組み
WASMとDOMの役割分担
Leptosのコードは、ビルドするとWASMになります。
ブラウザの中では、
- 状態管理や計算はWASM上のRustで実行
- 画面として見えるものはDOMとして表示
という形で動いています。
流れとしては、
- ユーザーがボタンを押す
- イベントをJSが受け取る
- WASMのRustコードが呼ばれる
- signalが更新される
- 変わった部分だけDOMに反映される
というイメージです。
DOMそのものをWASMが持っているわけではなく、
- ロジックと状態はWASM
- 表示の結果はDOM
という分担になっています。
WASMとJSの間の無駄を減らす設計
WASMとJSの間でやり取りをすると、どうしても少しコストがかかります。
Leptosでは、
- 仮想DOMのように大きな差分計算をせず
- 更新が必要なDOM操作だけをピンポイントで行う
ことで、このコストをできるだけ小さくしています。
そのため、
- 状態が細かく分かれたUI
- 頻繁に値が変わる画面
でも、軽快に動くように設計されています。
まとめ
この記事では、Leptosを使って、
- 開発環境を用意し
- テンプレートからプロジェクトを作り
- 開発サーバーを起動して画面を表示し
- コンポーネントとsignalで状態を扱う
ところまでを一通り試しました。
Rustで書いたコードがそのままブラウザで動く、
という体験ができたと思います。
今回わかったこと
この入門で、次のようなことに触れました。
- view! マクロでUIを書く感覚
- signalで状態を持ち、表示に反映する流れ
- EffectやMemoで値の変化に反応する仕組み
- Rustだけでフロントエンドが書けるということ
次にやってみるなら
ここから先は、
- もう少し小さなアプリを作ってみる
- Server Functionsでサーバー処理を試す
- コンポーネントを増やしてUIを整理する
といった方向に進むと、理解が深まります。
おわりに
Leptosを触ってみて、
Rustでフロントエンドも書けるんだ
と感じてもらえたなら、この入門の目的は達成です。
ここまで読んでいただき、ありがとうございました。



