One Rust core. Native everywhere.
Undra is a Rust framework for iOS, Android and web apps. Write logic, state and data once; the UI stays native: SwiftUI, Compose, React or React Native.
curl
curl -fsSL https://shreypdev.github.io/undra/install.sh | shcargo
cargo install --git https://github.com/shreypdev/undra undra-cli- reads stay local
- one crossing
- change-sets
- WriteA tap calls Rust once.
- CommitRust answers with one change-set.
- ApplyEvery screen's copy updates.
See it live.
The real Rust core, running here as WebAssembly.
Loads as you scroll.
- Generated
- –/ s
- Applied (merged per frame)
- –/ s
- Apply, average
- –
- Slowest drain
- –
- Dropped frames
- –
Your browser's clock rounds to 0.1 ms, so anything faster reads “< 0.1 ms”. Measured with a real clock, one change-set of 100 signals takes 2.3 µs.
Measured in your browser
A drain is the mirror applying what the core sent since the last frame to the local signals and notifying subscribers, timed with performance.now(). Browsers round that clock (Chrome to 0.1 ms, Firefox and Safari to 1 ms), so one drain faster than the step reads as “under” it. The average is a batch: every drain of the last two seconds, their times added up and divided by their number. Each reading is off by less than a step, too high or too low, and across a batch those errors even out, so the average is a real number below the step; it reads “under” only when the whole batch took less than one step. The slowest drain is one reading, so it is shown as a bound: under one step more than it read. Push it makes the core generate 10,000 updates a second on its own timer. The counters then also show how many it generated, how many entries the mirror applied after merging what arrived between two frames (and what share of those received), and the frames this page dropped, counted with requestAnimationFrame. Nothing here is a recording.
Calling Rust is cheap. Measured, not promised.
Every number links to its source.
Harsh conditions
How these are measured
Medians from cargo bench (criterion, release, LTO). Budgets are the design's per-device targets; CI gates are regression guards about 5x the host median. Harsh-conditions cards: 10-second sustained runs on the host, core side, on a shared machine; their gates are floors at one fifth of the measured rate. Frame-coalesced delivery (ADR-031) leaves these core-side numbers as they are: what it changes is the platform-side apply, and the live demo above shows that, measured in your browser (Push it). Measured on Apple M5 Pro, 18 cores, macOS 26.5. Simulator, emulator and Chromium rows are in the benchmark results; real-phone rows are not claimed and are on the roadmap.
Write the core. Generate the API. Keep your screens.
Write the core
Your logic and state, in Rust.
Generate the API
Swift, Kotlin and TypeScript.
Keep your screens
Reads are local; a change crosses once.
Show the generated code
Rust core
use undra::prelude::*;
/// One item of the to-do list.
#[undra::api]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub done: bool,
}
/// Why an item could not be added.
#[undra::error]
pub enum TodoError {
#[error("the title cannot be empty")]
EmptyTitle,
}
/// The to-do list: what every UI observes and calls.
#[undra::store]
pub struct Todos {
#[undra(key = "id")]
todos: Signal<Vec<Todo>>,
filter: Signal<Filter>,
#[undra(key = "id")]
visible: DerivedList<Todo>,
remaining: Computed<u32>,
}
#[undra::api(store)]
impl Todos {
pub async fn add(&self, title: String) -> Result<Todo, TodoError> { /* … */ }
pub fn set_filter(&self, filter: Filter) { /* … */ }
}SwiftUI
// SwiftUI: Todos is @Observable. Reading a signal is a plain property read.
struct TodosScreen: View {
let todos: Todos // try Todos() after UndraPlaygroundCore.load()
@State private var draft = ""
@State private var problem: String?
var body: some View {
List {
ForEach(todos.visible, id: \.id) { todo in
Button { todos.toggle(id: todo.id) } label: {
Label(todo.title, systemImage: todo.done ? "checkmark.circle.fill" : "circle")
}
}
}
.toolbar {
ToolbarItem(placement: .principal) { Text("\(todos.remaining) left") }
}
}
private func add() {
Task {
do { _ = try await todos.add(title: draft); draft = "" }
catch { problem = error.localizedDescription } // TodoError, CancellationError or UndraCallError
}
}
}Compose
// Compose: signals are StateFlows.
@Composable
fun TodosScreen(vm: TodosViewModel = viewModel()) {
val store = vm.store
val visible by store.visible.collectAsState()
val remaining by store.remaining.collectAsState()
Column {
Text("$remaining left")
LazyColumn {
// keyed by id in the core, so Compose moves rows instead of redrawing them
items(visible, key = { it.id }) { todo ->
TodoRow(todo, onToggle = { store.toggle(todo.id) })
}
}
}
}
// in the ViewModel
viewModelScope.launch {
try { store.add(draft) }
catch (e: TodoError) { /* TodoError.EmptyTitle */ }
}React
// React and React Native: hooks from @undra/runtime/react (vue, svelte and solid adapters ship too).
import { useUndra, useSignal } from "@undra/runtime/react";
import { Todos, TodoError } from "@playground/core";
export function TodosView() {
const store = useUndra(Todos); // undefined until it exists
const todos = useSignal(store?.visible);
const remaining = useSignal(store?.remaining);
async function add(title: string) {
try {
await store?.add(title);
} catch (error) {
if (error instanceof TodoError) { /* a typed error, not a string to parse */ }
}
}
/* … render todos, `${remaining} left` … */
}Queries
/// The items of a server list, cached per list, fresh for 30 s, persisted.
#[undra::query(key = "remote-todos:{list}", stale = "30s", persist, retry = 1)]
pub async fn remote_todos(ctx: &Ctx, list: String) -> Result<Vec<RemoteTodo>, RemoteError> {
let url = endpoint(ctx, &format!("/lists/{list}/todos"))?;
parse(&send(ctx, HttpRequest::get(url)).await?)
}
/// Shows the item at once; takes it back if the server refuses.
#[undra::api]
pub async fn create_remote_todo(ctx: &Ctx, list: String, title: String) -> Result<RemoteTodo, RemoteError> {
// …build `placeholder`…
ctx.mutate::<PostRemoteTodoMutation>((list, title))
.optimistic(move |cache| {
cache.update::<RemoteTodosQuery>((cached,), |items| items.push(placeholder));
})
.await
}Trust it because you can check it.
Tests
- Rust3,812
- TypeScript2,043
- Swift914
- Kotlin890
- React Native111
35 scenarios on 3 platforms
Reviewed by someone who did not write it
- every core crateffi under ASan + Miri
- every feature since
The reference app is real
See the proof →Three hosts. No drift.
One flow, recorded per platform; what differs is the host.
$ undra drift ios.json android.json web.json
Drift: ios.json (reference) against android.json, web.json (schema 0x3c17cd5f59bb6f68)
Ignored: the replies of Clock.* and Rng.*, the arguments of Timer.*, the Idempotency-Key header of Http.request, and t
android calls Todos.toggle on Todos#0 #5: missing on android ({"id":"00000000-0000-0001-0000-000000000000"})
android ports Http.request #1: reply differs: ios {"body":{"status":200,...},"status":"ok"}, android {"body":{"status":500,...},"status":"ok"}
web calls Todos.add on Todos#0 #6: extra on web ({"title":"Pay the rent"})
3 divergences on 2 of 3 recordingsFor apps on two or more platforms that must agree.
Build the part under the pixels once.
curl
curl -fsSL https://shreypdev.github.io/undra/install.sh | shcargo
cargo install --git https://github.com/shreypdev/undra undra-cliundra init myapp
cd myapp/web && npm install && npm run devAlready have an app? undra adopt ../MyApp adds a core to it and leaves your project files alone.