Comparison
Undra vs UniFFI and Crux: three ways to put Rust under a native app
If you have decided your logic lives in Rust, there are at least three serious ways to get it under a SwiftUI, Compose or React app: UniFFI, which generates bindings and stops there; Crux, which adds an architecture; and Undra, which adds reactive state, a data layer and a dev loop. They are layers of one stack more than rivals, and the cheapest layer is sometimes the right one.
Three layers, not three rivals
UniFFI owns the crossing: it generates the foreign-language side of a Rust library's API. Crux owns the shape of the app: an Elm-style core that never does I/O and a thin shell that does. Undra owns the state that sits between core and screen: signals the UI observes, a data layer, and the loop for developing both.
You can stop at any layer. A small Rust library with a handful of functions needs UniFFI and nothing else. An app whose state you want to reason about as a pure function of events may want Crux. Undra is for apps where the hard part is keeping native UI consistent with stateful, server-backed logic on three platforms. Undra does not build on UniFFI or Crux: it has its own wire format and a fixed 19-function C ABI, so nothing below is a statement that one is built on another.
UniFFI: a binding generator
UniFFI is Mozilla's toolkit for generating foreign-language bindings for Rust libraries (project). You describe the interface with proc-macros or a UDL file, build a shared library, and run the generator. It has first-party support for Kotlin, Swift and Python (with more limited Ruby support), and third-party bindings exist for others, including JavaScript and WebAssembly, Kotlin Multiplatform, Go and C#. Mozilla uses it extensively in Firefox on mobile and desktop, and describes it as ready for production but a long way from 1.0.
Illustrative: UniFFI proc-macro usage
uniffi::setup_scaffolding!();
#[derive(uniffi::Record)]
pub struct Todo { pub id: u64, pub title: String, pub done: bool }
#[uniffi::export]
pub fn add_todo(list: Vec<Todo>, title: String) -> Vec<Todo> { /* … */ }
The mechanics are the point. UniFFI generates a C-style FFI layer over primitive types. Simple values cross directly; strings, options and records are lowered into a byte buffer the project calls a RustBuffer, and lifted on the other side (how it works). Every call your app makes is an FFI call. The generated Kotlin loads the library through JNA. It supports async functions, callback interfaces and foreign traits, and its Swift and Kotlin bindings check API checksums when the library initialises, which catches a binding and a library built from different interfaces. That last point is worth saying because Undra's schema-hash gate is not unique to Undra.
UniFFI is deliberately a lower layer. As far as its documentation goes it has no notion of observable state, a cache or a dev loop, and that documentation is candid that it offers no end-to-end packaging solution: it will not compile your Rust for Android or package the bindings into an .aar. That is a design choice, not a gap. But it means that if a SwiftUI screen must stay in sync with Rust state, you design the synchronisation: polling, or callback interfaces that push changes, and some representation of "what changed". Undra's mirror and change-sets are one answer to exactly that design problem.
Crux: an architecture
Crux, maintained by Red Badger, splits an app into a Core in Rust and a Shell in the platform's language (project). The core follows the Elm architecture. You define an Event enum, a Model, a ViewModel and an Effect type, and an update function from event and model to a Command that requests effects. The core never performs I/O. It describes the work as data, the shell performs it and hands the outcome back, and the core continues. A shell is therefore a small loop: send an event, perform each requested effect and resolve it, and read the view model when a render is requested. Its README states that Crux is pre-1.0 and production-ready, with occasional breaking changes, and it links engineering write-ups from Proton and Photoroom.
Illustrative: the shape of a Crux app, not compilable as written
impl App for Todos {
fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
match event {
Event::Add(title) => { model.items.push(Item::new(title)); render() }
Event::Refresh => Http::get(API_URL).expect_json().build().then_send(Event::Loaded),
Event::Loaded(Ok(mut resp)) => { model.items = resp.take_body().unwrap(); render() }
// ...
}
}
fn view(&self, model: &Model) -> ViewModel { /* project the model for the UI */ }
}
The boundary is three calls on a bridge: update takes an event and returns effect requests, resolve hands an effect's output back, and view returns the current view model. All messages are serialised with Bincode; types for Swift, Kotlin and TypeScript are generated with facet-generate, and the README names BoltFFI as the generator of the FFI layer. Its published capabilities include Render, Http, KeyValue and Time, at what the README calls various stages of maturity. Shells can be SwiftUI, Compose, React or Vue, or Rust web frameworks such as Leptos and Yew, and a Rust shell can import the core directly.
View models and change-sets
This is the deepest architectural difference between Crux and Undra, so it deserves care.
In Crux the shell renders a ViewModel. When the core asks for a render, the shell calls view() and receives the whole view model, serialised across the boundary. That is a clean contract: the UI is a function of one value, consistency is trivial, and the view model is easy to assert on in tests. The cost scales with the size of the view model, not the size of the change, and the shell must work out what changed, which SwiftUI and React already do by diffing. For a settings screen this is unremarkable. For a long list that changes one row at a time, the work per update is proportional to the list.
Crux is not stuck there. Photoroom's engineering blog describes running into this with a large view model, where the UI could not tell what had changed, and building patches on top: key-path changes generated by diffing view models before and after an update, and sending only those (Photoroom, part 3). In Crux that is something you build; in Undra it is the built-in model.
In Undra the state that crosses is per signal, and each platform observes the signals it renders. Changing the to-do filter ships the new filter and the recomputed visible list and nothing else, because the underlying todos did not change. A transaction commits one change-set per store it touched, holding only the observed signals that changed, each as a full value or, for a list with a key, as a patch of insert, remove, update and move operations that the core recorded as the store performed them (decision record ADR-027). On a 10,000-row observed list, one insert measures 6.31 µs core-side as a recorded operation against 536.16 µs when the core had to diff, on an Apple M5 Pro (benchmark results). Those are Undra against an earlier Undra, not against Crux.
Undra's costs here are real. Computed lists cross as full values, because only a keyed Signal<Vec<T>> gets patches. The fast path is opt-in: a store must use operations like insert and update_at rather than an arbitrary update closure, which falls back to an O(list) diff. And the platform keeps a mirror, which is memory Crux does not need. If your view models are small, Crux's simplicity is the better trade.
Effects as data, or ports
Crux's effects are data: the core requests, the shell performs, the result returns as an event. Its README highlights two consequences. The core stays sandboxed and portable, including inside wasm, and tests play the role of the shell, observing and resolving effects with no fakes, mocks or stubs. Commands can be written with async Rust, but the async block talks to the shell through a context rather than touching the model directly, so state changes live in update.
Undra takes the other road. Core code awaits a port, an ordinary Rust trait that the platform implements, and continues in place:
Real playground code: examples/playground/core/src/remote.rs
#[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?)
}
Behind send is the Http port. There are ten standard ports (Http, Kv, SecureStore, Fs, Clock, Rng, Log, Timer, Connectivity, Lifecycle), with default adapters on each platform and deterministic fakes in Rust. The core reads no clock and no random source and starts no thread outside the runtime, so it is deterministic and testable, but through fakes you supply rather than through pure functions. The query macro then gives every platform the same staleness window, request deduplication, retry, optimistic updates with rollback, offline queue and persistence, where a Crux app builds its own caching on top of the Http capability (we found no cache in Crux's published capabilities or in crux_http's documentation as of September 2026).
The trade is what you can reason about locally. A Crux update is a pure function you can read top to bottom. Undra's async methods are ordinary Rust that hold state across awaits, which is shorter to write and does more work for you, and harder to reason about exhaustively.
What the UI code looks like
With UniFFI your SwiftUI or Compose code calls generated functions such as addTodo(list:title:) and gets values back. How those values reach a view, and how the view learns of later changes, is yours to build. With Crux the UI renders the latest view model and sends events; the shell's plumbing is the loop above. With Undra a view reads properties of a store, and the runtime has already put the current values there:
Playground code, abbreviated: examples/playground/ios/PlaygroundApp/Screens/TodosScreen.swift
ForEach(todos.visible, id: \.id) { todo in
Button {
todos.toggle(id: todo.id)
} label: {
Label(todo.title, systemImage: todo.done ? "checkmark.circle.fill" : "circle")
}
// ...
}
That is ordinary SwiftUI. toggle is a write that crosses once; todos.visible is a read that does not.
Side by side
| Aspect | UniFFI | Crux | Undra |
|---|---|---|---|
| What it is | Binding generator for Rust libraries | Elm-style app architecture with a shell per platform | State, data layer and dev loop with generated bindings |
| Maintained by | Mozilla | Red Badger and contributors | The Undra team |
| What crosses | Function calls; complex values lowered to byte buffers | Serialised events, effect requests and responses in; the view model out | Calls in; binary change-sets out |
| Where state lives | Wherever you design it | Model in the core, projected to a ViewModel | Signals in stores, mirrored to each platform |
| How the UI reads | Calls you design | view() returns the whole view model | A local mirror; reads never cross |
| I/O | Your Rust calls out, or foreign callbacks | Effects as data, run by the shell | Ports answered by platform adapters |
| Data layer | None | Capabilities such as Http and KeyValue; caching is yours | Queries, mutations, offline queue, persistence |
| Web | Third-party bindings | Wasm core; React, Vue and Rust-based shells | Wasm core plus a TypeScript package with hooks |
| Generated code | Swift and Kotlin APIs mirroring your Rust interface | Types for events, effects and view models | Observable stores, typed errors, hooks, StateFlows |
| Load-time check | Checksums at initialisation | Generated types that fail to compile on change | Schema-hash gate at load and at build |
| Status (Sep 2026) | Production use at Mozilla; pre-1.0 | Production-ready, pre-1.0 | v1.0 shipped 30 Sep 2026 |
When UniFFI is the better choice
- You want bindings, not a framework. If you have a Rust library and need Swift and Kotlin callers, UniFFI is smaller and simpler to adopt than anything that also brings a runtime.
- You already have state management on each platform. If each app has its own architecture and you want to move only the computation, a generator of call bindings fits without asking you to adopt mirrors or signals.
- Your API is objects and callbacks. UniFFI exposes Rust objects with methods, supports callback interfaces and foreign traits, and maps async Rust functions to each language's native futures. Undra's surface is stores, records, methods and ports.
- You need other languages. Python and Ruby are in the tree and third-party generators cover Go, C# and more. Undra targets Swift, Kotlin and TypeScript.
- You want the longer track record. It is used extensively in Firefox and maintained by Mozilla. Undra is a new v1.0.
- Your surface is small. A few functions do not need schema-driven stores.
When Crux is the better choice
- You want Elm's discipline. Every state change is an event, the core is a pure function of events and resolved effects, and tests are plain Rust with no fakes. Some teams find that the most maintainable way to build an app, and Undra does not impose it.
- Your view models are small and whole-state updates are fine. Then Crux's single-value contract is simpler than signals and patches.
- You want Rust on the web side too. Crux supports Rust web shells such as Leptos and Yew and lets a Rust shell import the core directly.
- You need a C# shell. Crux's README says its FFI layer is generated across native, web and C# shells. Undra targets Swift, Kotlin and TypeScript.
- You want a community with production stories. Crux's README links write-ups from Proton and Photoroom. Undra has a reference app and a test suite, not production mileage.
- You do not need a built-in data layer. If Http, KeyValue and Time capabilities are enough and you would rather own caching and sync yourself, Crux stays out of the way.
Where Undra fits
Choose Undra when you want platform UIs to bind to Rust state directly, with lists that stay cheap as they grow, server data with the semantics of a cache, offline support that is tested on all three platforms, and a dev loop where the core runs on your machine. Read why reads never cross the boundary for the model, how Undra compares with Kotlin Multiplatform, or start with the getting started guide.