Reactive UI framework for Rust — Web & Desktop
Build interactive web and desktop applications using only Rust. No JavaScript required.
Planning ✅ Development ✅ Testing ✅ Release 🔄
Multi-platform · Alpha v0.1.0
- What is Domus? — Introduction for beginners
- Quick Start — Get running in 5 minutes
- Your First Component — Build something real
- Core Concepts — Signals, Effects, Batching
- Building Applications — Components, Pages, Router
- Architecture — How it works under the hood
- Advanced Topics — Context, Lists, Scoped CSS
- CLI Reference — Scaffold projects quickly
- Testing — Run the test suite
- Roadmap — What's coming next
Most UI frameworks for Rust either target only the web (WASM) or require complex JavaScript tooling. Building cross-platform apps usually means maintaining separate codebases.
Domus uses signals — a smarter way to track changes. When data changes, only the exact text or element that depends on it updates. Nothing else moves.
Think of it like a spreadsheet:
- Cell A1 contains
5 - Cell B1 contains
=A1 * 2 - When you change A1 to
10, B1 automatically becomes20
Domus brings this reactive model to both web and desktop UIs with a single codebase.
flowchart TB
subgraph App["Your Application"]
Code[Your Components & Pages]
end
subgraph Backend["Choose Your Backend"]
direction LR
Web[domius-web<br/>WASM/Web]
Desktop[domius-desktop<br/>Tauri]
end
subgraph Core["domius-core<br/>100% Rust, platform-agnostic"]
Reactive[Signal · Effect · Scope]
end
Code --> Web
Code --> Desktop
Web --> Reactive
Desktop --> Reactive
style App fill:#e3f2fd
style Backend fill:#fff3e0
style Core fill:#c8e6c9
| Benefit | What it means |
|---|---|
| Fast | O(1) updates — changing one signal doesn't re-render the whole page |
| Small | No Virtual DOM = less code = smaller bundles |
| Simple | Write Rust, not JavaScript. No complex build chains |
| Precise | Only the exact node that changed gets updated |
| Cross-platform | Same code for Web (WASM) and Desktop (Tauri) |
✅ Good fit:
- Dashboards with live data
- Real-time applications
- Projects where you want to avoid JavaScript
- Performance-critical UIs
- Cross-platform apps (web + desktop)
❌ Not ideal:
- Static content sites (use a simpler tool)
- Projects requiring heavy JavaScript ecosystem
Install these tools first:
# Rust toolchain (if you don't have it)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# WASM pack — builds Rust to WASM (for web projects)
cargo install wasm-pack
# Domus CLI — project scaffolding
cargo install domius-cli# Scaffold a new web project
domus new project my-app-web
cd my-app-web
# Build for web (creates a pkg/ folder)
wasm-pack build --target web
# Serve locally (requires Node.js)
npx serve .Open http://localhost:3000 in your browser.
# Scaffold a new desktop project
domus new project my-app-desktop --tauri
cd my-app-desktop
# Install Tauri CLI
cargo install tauri-cli
# Run in development mode
cargo tauri devmy-app/
├── Cargo.toml # Rust dependencies
├── index.html # Entry point (web) / src-tauri/tauri.conf.json (desktop)
└── src/
├── lib.rs # Your main Rust code
└── routes.rs # URL routing (web) or window management (desktop)
In your Cargo.toml:
# For Web
[dependencies]
domius-core = "0.1" # Reactive core (always required)
domius-web = "0.1" # Web backend (WASM)
# For Desktop
[dependencies]
domius-core = "0.1" # Reactive core (always required)
domius-desktop = "0.1" # Desktop backend (Tauri)Why two crates?
domius-core— Platform-agnostic reactive runtime (Signal, Effect, Scope). Use this for platform-independent code.domius-web/domius-desktop— Platform-specific backends (DOM manipulation, window management).
In your code:
// Platform-independent code (shared between web and desktop)
use domius_core::signal::{signal, Signal};
use domius_core::effect::create_effect;
// Platform-specific initialization
#[cfg(target_arch = "wasm32")]
fn main() {
domius_web::init();
// ...
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
domius_desktop::init();
// ...
}Your component logic remains the same — only the initialization changes!
Let's build a counter — the "Hello World" of reactive UIs.
Create src/components/counter/mod.rs:
use domius_web::component::{DomusComponent, DomusNode};
use domius_core::signal::signal;
// The component struct (empty — state goes in CounterState)
pub struct Counter;
// Props: data passed from parent
#[derive(Clone)]
pub struct CounterProps {
pub initial: i32,
}
// State: internal reactive data
pub struct CounterState {
pub count: Signal<i32>,
pub set_count: WriteSignal<i32>,
}impl DomusComponent for Counter {
type Props = CounterProps;
type State = CounterState;
fn setup(props: CounterProps) -> CounterState {
// Create a signal — a reactive value
let (count, set_count) = signal(props.initial);
CounterState { count, set_count }
} fn render(state: &CounterState) -> DomusNode {
domus! {
<div class="counter">
<h2>"Counter"</h2>
<p>"Count: " {state.count.get()}</p>
<button on_click={move |_| {
let current = state.count.get();
state.set_count.set(current + 1);
}}>
"Increment"
</button>
</div>
}
}
}In src/lib.rs:
use domius_web::component::mount_component;
use crate::components::counter::{Counter, CounterProps};
#[wasm_bindgen(start)]
pub fn main() {
domius_web::init();
let app = document().get_element_by_id("app").unwrap();
mount_component::<Counter>(&app, CounterProps { initial: 0 });
}- User clicks "Increment"
set_count.set()updates the signal- Domus automatically re-runs any code that read
count.get() - Only the
<p>text node updates — nothing else re-renders
A Signal<T> is a reactive container. It holds a value and notifies listeners when it changes.
use domius_core::signal::signal;
// Create a signal
let (count, set_count) = signal(0i32);
// Read the value (tracks dependencies if inside an effect)
let current = count.get();
// Write a new value (notifies all subscribers)
set_count.set(current + 1);Key insight: Reading a signal inside an effect automatically registers that effect as a subscriber.
An Effect runs code whenever its dependent signals change.
use domius_core::effect::create_effect;
create_effect(move || {
// This runs once immediately...
web_sys::console::log_1(&format!("Count: {}", count.get()).into());
// ...and again every time `count` changes
});Effects are the engine of reactivity. They're used internally by Domus to update the DOM.
When you update multiple signals, you can batch them to avoid redundant updates.
use domius_core::batch::batch;
batch(|| {
set_x.set(1);
set_y.set(2);
set_z.set(3);
// All effects fire ONCE after the closure returns
});Without batching, each .set() would trigger its own round of updates.
A Scope groups effects together so they can be cleaned up at once. This prevents memory leaks when components are removed.
use domius_core::scope::{create_scope, dispose_scope, create_effect_in_scope};
let scope = create_scope(None);
create_effect_in_scope(scope, move || {
// This effect is tied to the scope
});
// Later, when the component unmounts:
dispose_scope(scope);
// All effects in this scope are automatically unsubscribedRule of thumb: One scope per component. Dispose it when the component unmounts.
Components are reusable UI building blocks. They follow a simple pattern:
pub struct MyComponent;
#[derive(Clone)]
pub struct MyComponentProps {
pub title: String,
}
pub struct MyComponentState {
// Your reactive state here
}
impl DomusComponent for MyComponent {
type Props = MyComponentProps;
type State = MyComponentState;
fn setup(props: MyComponentProps) -> MyComponentState {
// Initialize state
}
fn render(state: &MyComponentState) -> DomusNode {
// Return UI
}
}Pages are special components that represent full screens with URLs.
use domius_web::page::DomusPage;
impl DomusPage for DashboardPage {
fn route() -> &'static str { "/dashboard" }
fn title(_state: &DashboardState) -> String {
"Dashboard".into()
}
}Navigate between pages with pattern matching.
use domius_web::router::Router;
use std::collections::HashMap;
let mut router = Router::new();
// Exact match
router.register("/", home_handler);
// With parameters
router.register("/users/:id", user_handler);
// Wildcard
router.register("/files/*", files_handler);
// Match a URL
if let Some((handler, params)) = router.match_route("/users/42") {
// params["id"] == "42"
handler(¶ms);
}Use the CLI to scaffold:
# Add a component
domus add component NavBar
# Creates: src/components/nav_bar/{mod.rs, view.rs, style.css}
# Add a page
domus add page Dashboard
# Creates: src/pages/dashboard/{mod.rs, controller.rs, view.rs, style.css}Domus uses a layered architecture that separates reactive core from platform-specific backends:
flowchart TB
subgraph App["Your Application"]
A[Your Components & Pages]
end
subgraph Backends["Platform Backends"]
B1[domius-web<br/>WASM/Web]
B2[domius-desktop<br/>Tauri]
end
subgraph Core["domius-core<br/>100% Rust std"]
C[Signal · Effect · Scope · Batch]
end
subgraph Macro["domius-macro<br/>RSX Parser"]
M[domus! macro]
end
A --> B1
A --> B2
B1 --> C
B2 --> C
B1 --> M
B2 --> M
style App fill:#e3f2fd
style Backends fill:#fff3e0
style Core fill:#c8e6c9
style Macro fill:#f3e5f5
| Crate | Purpose | Platform |
|---|---|---|
domius-core |
Reactive primitives: Signal, Effect, Scope, batch |
All |
domius-web |
Component system, router, DOM manipulation | Web (WASM) |
domius-desktop |
Component system, Tauri integration | Desktop (Tauri) |
domius-macro |
RSX proc-macro: parses declarative UI syntax | All |
domius-cli |
CLI scaffolding (domus new, domus add) |
All |
| Feature | domius-web | domius-desktop |
|---|---|---|
| Target | WASM in browser | Native window (Tauri) |
| DOM | web_sys APIs |
Tauri webview |
| Events | Browser events | Tauri commands |
| Disposal | MutationObserver |
Window close events |
| Bundle | .wasm + JS |
Native binary |
The core is identical for both platforms:
flowchart LR
subgraph User["Your Code"]
U[Components]
end
subgraph Backend["Backend"]
B[web/desktop]
end
subgraph Core["domius-core"]
S[Signal]
E[Effect]
SC[Scope]
end
U --> B
B --> Core
// 1. Effect starts running
create_effect(move || {
// 2. Signal.get() checks: "Is there a running effect?"
// Yes! Registers this effect as a subscriber
let value = my_signal.get();
// 3. Effect finishes. Dependencies are now tracked
});
// 4. Later, signal.set() notifies all subscribers
my_signal.set(42);
// Effect re-runs automaticallyThis happens via thread-local storage (TLS) — no manual subscription needed.
When a signal changes, effects are scheduled and executed efficiently:
flowchart TD
Start[Signal.set] --> Batch{In batch?}
Batch -->|Yes| PQ[Primary Queue]
Batch -->|No| Flush{Is flushing?}
Flush -->|Yes| SQ[Secondary Queue]
Flush -->|No| Exec1[Execute immediately]
PQ --> FlushQ[Flush Generation]
SQ --> FlushQ
FlushQ --> Dedup[Deduplicate effects]
Dedup --> Exec2[Execute each once]
Exec2 --> New{New effects?}
New -->|Yes| PQ
New -->|No| Done[✅ Done]
style Start fill:#bbdefb
style Done fill:#c8e6c9
style FlushQ fill:#fff9c4
Benefits:
- ✅ Nested batches work correctly
- ✅ Diamond dependencies execute once (not twice)
- ✅ Glitch-free: derived values see consistent state
- ✅ Re-entrancy safe: epoch system prevents infinite loops
Domius provides built-in components for embedding video and audio content.
use domius_web::components::video_player::{video_player, VideoPlayerProps};
let video = video_player(VideoPlayerProps {
src: "https://example.com/video.mp4".to_string(),
controls: true,
auto_play: false,
loop_video: false,
poster: Some("https://example.com/poster.jpg".to_string()),
class: Some("my-video".to_string()),
});use domius_web::components::audio_player::{audio_player, AudioPlayerProps};
let audio = audio_player(AudioPlayerProps {
src: "https://example.com/audio.mp3".to_string(),
controls: true,
auto_play: false,
loop_audio: false,
class: Some("my-audio".to_string()),
});For advanced use cases, you can build custom controls using domius_core signals:
use domius_core::signal::{signal, Signal};
use domius_core::effect::create_effect;
use web_sys::HtmlMediaElement;
let (is_playing, set_is_playing) = signal(false);
let (current_time, set_current_time) = signal(0.0);
// Connect to video element
create_effect(move || {
if is_playing.get() {
video_element.play().ok();
} else {
video_element.pause().ok();
}
});Share data across the component tree without passing props everywhere.
use domius_web::context::{provide_context, use_context};
// Near the root of your app
provide_context(AppConfig {
theme: "dark".into(),
lang: "en".into(),
});
// Deep in a child component
if let Some(config) = use_context::<AppConfig>() {
println!("Theme: {}", config.theme);
}Efficiently update lists by tracking items with unique keys.
use domius_web::list::{diff_keys, DiffOp};
let old = vec!["a", "b", "c"];
let new = vec!["b", "d", "a"];
let patch = diff_keys(&old, &new);
// patch tells you:
// - Which items to remove
// - Which to keep (and where)
// - Which to insertThis is O(N+M) — linear time, not quadratic.
Every component gets its own CSS namespace automatically.
/* Input: src/components/button/style.css */
.btn { color: red; }
.icon { width: 16px; }Gets compiled to:
/* Output */
[data-domus="a3f2b1c0"] .btn { color: red; }
[data-domus="a3f2b1c0"] .icon { width: 16px; }Styles cannot leak between components.
The domus! macro accepts two syntaxes.
Rust style:
domus! {
div(class: "card") {
h2 { "Hello" }
p { "Count: " {count} }
}
}HTML style:
domus! {
<div class="card">
<h2>Hello</h2>
<p>Count: {count}</p>
</div>
}Both compile to the same web_sys DOM code.
When a component's DOM node is removed, its effects are automatically cleaned up.
// In your component's render:
let root = domus! { <div data-domus-scope={scope_id}>...</div> };
// When this div is removed from the DOM:
// → MutationObserver detects removal
// → Scope is disposed
// → All effects unsubscribeCall domius_web::init() once at startup to enable this.
# Create a new project
domus new project my-app
# Add a component
domus add component NavBar
# Add a page
domus add page DashboardThe CLI auto-converts names:
| You type | File/folder | Rust struct |
|---|---|---|
NavBar |
nav_bar/ |
NavBar |
my-page |
my_page/ |
MyPage |
dashboard |
dashboard/ |
Dashboard |
Run all tests:
cargo test --workspace --exclude hello-worldTest breakdown:
domius-cli 35 tests CSS scoper · scaffolding · naming
domius-core 19 tests signals · effects · batching · diamonds
domius-macro 38 tests RSX parsing · codegen
domius-web 62 tests components · router · context · lists
────────────────────────────────────────────────
154 tests all passing
Tests run natively (no WASM runtime needed). WASM-specific code is stubbed out for testing.
Done:
- ✅ Signal/Effect reactive core (platform-agnostic)
- ✅ Batch system with nested batches
- ✅ Scope-based disposal
- ✅ Diamond convergence (single execution)
- ✅ Re-entrancy prevention
- ✅ Glitch-free updates
- ✅ RSX macro (Rust + HTML syntax)
- ✅ Component system
- ✅ Page system with routing
- ✅ Context API
- ✅ Keyed list reconciliation
- ✅ Automatic CSS scoping
- ✅ CLI scaffolding
- ✅ Web backend (
domius-web) with MutationObserver disposal - ✅ Desktop backend (
domius-desktop) with Tauri integration
Coming soon:
-
cargo clippyclean pass - Public API documentation (
cargo doc) - Publish to crates.io
- Dev server with file watching
- Error boundaries
- Server-side rendering (SSR) / hydration
- Mobile support (iOS/Android via Tauri Mobile)
See examples/hello-world for a complete counter + todo list demo (~200 lines).
cd examples/hello-world
wasm-pack build --target web
npx serve .See examples/hello-world-tauri for a desktop counter application.
cd examples/hello-world-tauri
# Install Tauri CLI if needed
cargo install tauri-cli
# Run in development mode
cargo tauri dev
# Build for production
cargo tauri buildNote: Building Tauri apps requires additional system dependencies. See the Tauri documentation for details.
Detailed view of how effects are scheduled and deduplicated:
flowchart TD
subgraph SignalWrite["Signal Write"]
S[signal.set]
end
subgraph Queues["Queue System"]
direction TB
PQ[Primary Queue<br/>Ready effects]
SQ[Secondary Queue<br/>Next generation]
end
subgraph Flush["Flush Process"]
direction TB
Gen[Start Generation]
Drain[Drain Primary Queue]
Exec[Execute Effects]
Move[Move Secondary → Primary]
end
subgraph Exit["Exit"]
Done[✅ All effects executed]
end
S --> PQ
PQ --> Gen
Gen --> Drain
Drain --> Exec
Exec --> SQ
SQ --> Move
Move --> Drain
Drain -->|Queue empty| Done
style SignalWrite fill:#e3f2fd
style Queues fill:#fff3e0
style Flush fill:#c8e6c9
style Exit fill:#f3e5f5
How Domus prevents infinite loops when effects write signals:
stateDiagram-v2
[*] --> Gen1: Generation 1 starts
state Gen1 {
Exec[⏳ Executing effects]
Mark[Mark EXECUTED_THIS_GEN]
Exec --> Mark
end
Gen1 --> Try{Effect tries to<br/>reschedule?}
Try --> Same: Same epoch
Try --> Next: Next epoch
Same --> Block[❌ BLOCKED<br/>Already executed]
Next --> Defer[⏱️ Deferred to<br/>Secondary Queue]
Block --> Gen2
Defer --> Gen2
Gen2: Generation 2<br/>Process Secondary Queue
Gen2 --> [*]
style Gen1 fill:#bbdefb
style Gen2 fill:#bbdefb
style Block fill:#ffccbc
style Defer fill:#c8e6c9
Result: Effects can safely write signals during execution without causing infinite loops.
When multiple paths lead to the same effect, it executes only once:
flowchart TD
A[Signal A] --> B[Effect B]
A --> C[Effect C]
B --> D[Effect D]
C --> D
A -.->|change| E{Flush}
E -->|notify| B
E -->|notify| C
E -->|notify| D
subgraph Execution["Execution Order"]
B -->|1st| D
C -->|skipped| D
end
style A fill:#e3f2fd
style D fill:#c8e6c9
style Execution fill:#fff3e0
Key insight: The generation-based flush system tracks which effects have already executed in the current generation, ensuring D runs exactly once.
Why Domus uses Cell<Option<FnMut>> instead of RefCell<FnMut>:
flowchart LR
subgraph Old["❌ RefCell Pattern"]
RC[Rc<RefCell<FnMut>>]
Borrow[borrow_mut]
Conflict[Borrow conflict!<br/>Panic]
RC --> Borrow
Borrow --> Conflict
end
subgraph New["✅ Cell Pattern"]
CE[Cell<Option<FnMut>>]
Take[Take closure]
Exec[Execute]
Return[Put back]
CE --> Take
Take --> Exec
Exec --> Return
Return --> CE
end
style Old fill:#ffcdd2
style New fill:#c8e6c9
style Conflict fill:#ef9a9a
Why it matters: RefCell panics if you try to borrow mutably while already borrowed. Cell::take() avoids this by temporarily removing the value.
MIT — use it freely.
- New to Rust? Start with The Rust Book
- New to WASM? See Rust and WebAssembly
- Found a bug? Open an issue on GitHub
- Want to contribute? Check out the roadmap above