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 | sh

cargo

cargo install --git https://github.com/shreypdev/undra undra-cli
Architecturetap a screen
How Undra works Four native UIs (SwiftUI on iOS, Jetpack Compose on Android, React on the web, React Native on iOS and Android) sit above the Rust core. A write from any UI crosses the language boundary once per transaction. The core commits one binary change-set, which fans out to every UI's local mirror. Reads never cross the language boundary. reads never cross one crossing per transaction binary change-sets the language boundary SwiftUI iOS · @Observable add(title: "milk") reads stay local 2 left Write the spec Ship the core Buy milk Compose Android · StateFlow add("milk") reads stay local 2 left Write the spec Ship the core Buy milk React Web · useSignal add("milk") reads stay local 2 left Write the spec Ship the core Buy milk React Native iOS + Android add("milk") reads stay local 2 left Write the spec Ship the core Buy milk Rust core stores · queries · mutations · persistence · offline queue store · Todos change-set · one transaction todos filter visible remaining ChangeSet todos·patch visible remaining only what changed is sent core call 49.8 ns · change-set, 100 signals 2.3 µs ports Http · Kv · SecureStore · Fs · Clock · Rng · Log · Timer · Connectivity · Lifecycle commitcommit How Undra works Four native UIs (SwiftUI on iOS, Jetpack Compose on Android, React on the web, React Native on iOS and Android) sit above the Rust core. A write from any UI crosses the language boundary once per transaction. The core commits one binary change-set, which fans out to every UI's local mirror. Reads never cross the language boundary. 1 2 3 SwiftUI iOS 2 store.add() local reads Compose Android 2 store.add() local reads React Web 2 store.add() local reads React Native iOS + Android 2 store.add() local reads Rust core store · Todos todos filter visible remaining change-set ChangeSet todos visible remaining call 49.8 ns · 100-signal change-set 2.3 µs ports Http · Kv · SecureStore · Fs · Clock · Rng · Log · Timer · Connectivity · Lifecycle commitcommit
  • reads stay local
  • one crossing
  • change-sets
  1. WriteA tap calls Rust once.
  2. CommitRust answers with one change-set.
  3. ApplyEvery screen's copy updates.
Live demo

See it live.

The real Rust core, running here as WebAssembly.

playground · 10k list

Loads as you scroll.

Applied (merged per frame)
–/ s
Apply, average
–
Slowest drain
–

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.

Numbers

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.

How it works

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

core/src/todos.rs
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

TodosScreen.swift
// 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

TodosScreen.kt
// 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

TodosView.tsx
// 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

core/src/remote.rs
/// 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
}
Receipts

Trust it because you can check it.

Tests

7,770
  • Rust3,812
  • TypeScript2,043
  • Swift914
  • Kotlin890
  • React Native111
1.2

Three hosts. No drift.

One flow, recorded per platform; what differs is the host.

shell
$ 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 recordings
Why Undra

For apps on two or more platforms that must agree.

The limitations matrix →

Build the part under the pixels once.

curl

curl -fsSL https://shreypdev.github.io/undra/install.sh | sh

cargo

cargo install --git https://github.com/shreypdev/undra undra-cli
then
undra init myapp
cd myapp/web && npm install && npm run dev

Already have an app? undra adopt ../MyApp adds a core to it and leaves your project files alone.