Open-source project
rxRust/rxRust avatar
rxRust/rxRust

rxRust: Reactive Extensions for Rust, with a context chosen at compile time

Zero-cost & Type-safe Reactive Extensions for Rust. Supports both high-performance single-threaded (Local) and thread-safe (Shared) contexts. WASM & Tokio ready.

1,122 stars71 forksRustMIT

At a glance

What is it?
rxRust is a zero-cost, type-safe implementation of Reactive Extensions in Rust, and its central idea is that the context, Local or Shared, is a compile time choice that decides whether your streams use Rc and RefCell or Arc and Mutex. The same operator chain works in both, and the v1.0 line is still a release candidate with a nightly feature for lifetime dependent mapped outputs.
Who is it for?
rxRust fits a Rust developer who wants a stream abstraction with typed operators and needs the same logic to run on a UI thread and across worker threads, and who is willing to work with a 1.0 release candidate. It is the wrong choice if you want the widest operator set, since the project maintains a missing features list, and the wrong choice if you need a stable published release rather than an rc.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 12 days ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The context is the design decision, and it is made once

rxRust is a zero-cost, type-safe Rust implementation of Reactive Extensions, and it brings Functional Reactive Programming to Rust, enabling a declarative coding style for handling asynchronous events, stream processing and concurrency. The description in the repository metadata adds that it supports both high-performance single-threaded, Local, and thread-safe, Shared, contexts, and that it is WASM and Tokio ready.

The context is the first concept in the documentation, and the reason it exists is a specific Rust problem. A reactive stream that crosses threads needs synchronisation, and synchronisation costs. A stream that never leaves its thread does not need it, and paying for it anyway is a waste. Most reactive libraries solve this by always using the expensive option, or by making you choose at runtime, or by making the whole library thread safe and leaving you to avoid the cost by being careful.

rxRust makes the choice part of the type. Local uses Rc and RefCell, and the compiler ensures these types do not leak across threads, which is what makes the no-locking claim enforceable rather than aspirational. Shared uses Arc and Mutex, and is required when streams need to jump across threads or share state globally.

The features list calls this pay-for-what-you-need: use Local for single-threaded performance without locking overhead, or Shared when thread synchronisation is actually required. And then unified logic, adaptive context: write your stream logic once, and the same operator chains adapt to different environments based on the context they run in.

That last point is what the v1.0 rewrite was for. The README says rxRust v1.0 unifies its API and implementation logic, while the Context, the environment, provides the most suitable resource types at compile time, and the API remains consistent regardless of whether you are in a single-threaded or multi-threaded environment. So the operator surface is written once against a generic context parameter, and Local and Shared are instantiations of it.

The quick start shows the two side by side:

rust
use rxrust::prelude::*;

fn main() {
    // 🟢 Local Context: No locks, high performance for single thread
    Local::from_iter(0..10)
        .filter(|v| v % 2 == 0)
        .subscribe(|v| println!("Local Even: {}", v));

    // 🔵 Shared Context: Thread-safe, ready for concurrency
    Shared::from_iter(0..10)
        .map(|v| v * 2)
        .subscribe(|v| println!("Shared Doubled: {}", v));
}

The two chains differ only in the context they are built from. The filter and the map are the same operators, and the cost difference is entirely in what the context parameterises.

Zero-cost abstractions is the other headline, and it is specific about the mechanism: heavily relies on generic specialization and monomorphization to compile down to efficient code, ensuring minimal overhead for your asynchronous streams. The library also leans on Rust's ownership system to ensure memory safety and efficient resource usage.

The primary language is Rust, the licence is MIT, the homepage is rxrust.github.io/rxRust, and the repository is not archived with the last push on 2026-10-11. There are no GitHub releases, so the crate version is the reference, and the Cargo.toml declares 1.0.0-rc.6 while the README's install snippet shows 1.0.0-rc.0, which is a small drift worth noticing in a project that is still moving.

Schedulers, timers and the wasm target

Schedulers are the second concept, and they answer when and where work happens.

By default, rxRust schedulers rely on tokio. You can also disable the default scheduler feature to implement a custom scheduler adapted to your own runtime. That is a sensible design, because tying a reactive library to one async runtime is the most common complaint about this family of libraries in Rust.

The example shows the two scheduler sources you actually reach for first:

rust
use rxrust::prelude::*;

#[tokio::main(flavor = "local")]
async fn main() {
    // Emit a value after 100ms
    Local::timer(Duration::from_millis(100))
        .subscribe(|_| println!("Tick!"));

    // Throttle a busy stream
    Local::interval(Duration::from_millis(10))
        .throttle_time(Duration::from_millis(100), ThrottleEdge::leading())
        .subscribe(|v| println!("Throttled: {}", v));
}

The timer and the interval are the two sources that produce values on a schedule rather than from data, and throttle_time with a ThrottleEdge is the operator that decides which edge of the window emits. ThrottleEdge::leading means the first value in each window wins, which is the behaviour you want for a UI update and the opposite of what you want for a value you need to keep, and having the edge explicit is the kind of detail that avoids a subtle bug.

There is a constraint on the Local context that is worth reading before you copy the example. Local context schedulers require running within a LocalSet or a compatible LocalRuntime. That follows from the design: a LocalSet is Tokio's way of running !Send futures on a current thread, and a Local scheduler cannot leave the thread it was created on.

The wasm story is handled in the manifest rather than in a separate library, and it is neat. The scheduler feature pulls in wasm-bindgen-futures and gloo-timers when the target is wasm32, and tokio when it is not. The target-specific dependency blocks make that explicit:

toml
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = {version = "1.48", optional = true, features = ["rt-multi-thread", "rt", "time"]}
[target.'cfg(target_arch = "wasm32")'.dependencies]
gloo-timers = {version = "0.3.0", optional = true, features = ["futures"]}
wasm-bindgen-futures = {version = "0.4.56", optional = true}
web-time = {version = "1.1"}

So on wasm the timers come from gloo-timers, which is the browser timer API, and the futures come from wasm-bindgen-futures. And web-time appears unconditionally, because the std time APIs in wasm are limited, so the timing types come from a crate that works everywhere.

The wasm target is also where the Local context earns its keep, since a browser event loop is single threaded by nature, and the documentation names WASM as one of the three cases for Local along with UI threads and single-threaded event loops.

Subjects, and the storage that follows the context

Subjects are the multicasting primitive, and the way rxRust handles them is the clearest illustration of the context design.

Subjects allow you to multicast events to multiple subscribers, and the Subject type automatically adapts its internal storage, Rc<RefCell<...>> versus Arc<Mutex<...>>, based on the context used to create it.

rust
use rxrust::prelude::*;

// Created in a Local context, this Subject uses RefCell internally (no Mutex)
let mut subject = Local::subject();

subject.clone().subscribe(|v| println!("Observer A: {}", v));
subject.clone().subscribe(|v| println!("Observer B: {}", v));

subject.next(1);
subject.next(2);

The key thing here is that the storage is not a separate choice. You do not construct a Subject with an Arc<Mutex> and then have to remember not to send it across threads. You create it through the context, Local::subject(), and the type that comes out is the one that fits that context, so the compiler will stop you if you try to use it in the wrong place.

That is the same principle as the streams themselves, applied to a different kind of type. A Subject is a producer, and producers in a reactive system need to be fed values from wherever the events originate. The context decides what that costs.

The subscribe calls take a clone of the subject, which is the multicast mechanism, and the two observers see every value. The next calls then push values into the subject, and the observers receive them.

The manifest shows one more piece of the design. The dependency list is rclite at 0.4, which is the Rc and RefCell equivalents, plus crossbeam-utils, pin-project-lite, smallvec and futures-core. rclite is the crate that provides the Local context's reference counting without the Send bound, and smallvec is the inline buffer, which matters for a stream library because the per-element allocation is otherwise the dominant cost. futures-core gives the Future and Stream traits it interoperates with.

The interoperability claim in the features list is that it integrates with Rust Futures, streams and async/await, and futures-core being a direct dependency rather than a reimplementation is what makes that true. The library is not competing with futures, it is built on the same traits.

The remaining piece is the macro crate. The dev dependencies include rxrust-macro as a path dependency on the macros/ directory, and the package's exclude list has macros, so the macro crate is excluded from the published package while being available for local development, which is the normal arrangement for a derive or attribute macro that is used internally.

The cost of a Local stream, and where the compiler helps you

The interesting part of the Local design is what it does at the type level, and the documentation is specific about it in two places.

The first is that the compiler ensures these types do not leak across threads. That is the whole point of choosing Rc and RefCell, and it is enforced rather than documented, because Rc is not Send and the compiler already knows that. A Local stream that you tried to send to a worker thread would not compile, and the error would point at the type rather than at a rule you forgot.

The second is in the Subject section, where the Subject type automatically adapts its internal storage to the context used to create it. So a Local::subject() gives you a Subject backed by RefCell, with no Mutex, and the compiler will not let that Subject cross a thread boundary, so the absence of locking is not a footgun. The unsafe part of a RefCell based design, which is that borrowing rules are only checked at runtime, is contained by the fact that a Local subject stays on one thread.

That containment is what makes the pay-for-what-you-need claim credible. A library that used Arc and Mutex everywhere would be safe to use anywhere and would pay for it everywhere. A library that used Rc everywhere would be fast but would simply not work across threads, with the failure at compile time. rxRust takes the third option and makes the choice explicit in the type of the stream.

The dependency is rclite at 0.4, and that choice of crate matters. The standard library's Rc and RefCell have carried a Send and Sync question for a long time, and rclite is the community crate that provides reference counting without the Send bound on the type, so that the Local context can be generic over a counter that does not require thread safety.

smallvec at 1.11 is the other dependency that explains a performance characteristic. In a stream library, the per-element cost dominates, and a stream that allocates a fresh Vec for every element is a stream that cannot be called zero-cost. smallvec gives an inline buffer for the common case of a small number of elements, so the allocation disappears for the typical stream and only appears when the stream actually grows.

crossbeam-utils and pin-project-lite round out the list. The first provides the atomic and thread-local utilities that the Shared context and the scheduler need. The second is the macro that makes it possible to project a pinned value out of a struct, which is what an async-aware stream implementation needs when a future holds a reference to its own buffer.

So the design is coherent: the context chooses the synchronisation strategy, the compiler enforces that the choice is respected, and the supporting crates are the ones that make each strategy cheap rather than merely correct.

The nightly feature, the release candidate, and what is still missing

Two things to know before you adopt this are the state of the release and the state of the operator set.

On the release. The Cargo.toml declares the version as 1.0.0-rc.6, and the README's install snippet shows 1.0.0-rc.0. There are no GitHub releases, and the crates.io badge in the README is the version reference. So this is a release candidate line, and the version you will actually get from a caret dependency will move within the rc series, which for a library at this stage is normal but worth pinning if you care about reproducibility.

On the nightly feature. rxRust targets stable Rust by default. For advanced use cases involving lifetime-dependent mapped outputs, for example &'a mut T -> &'a U, map provides an experimental implementation behind the Cargo feature nightly. You enable it with rxrust = { version = "1.0.0-rc.0", features = ["nightly"] } and build with cargo +nightly test --features nightly.

And the reason is stated, which is unusual and useful. Once the relevant language support, like GATs stabilisation in specific patterns, is mature, the project plans to expand this support. So the nightly path is not a hack around a missing feature in the library, it is a workaround for a missing feature in the language, and the project has said which language feature it is waiting on. That is a much better position to be in than an unmaintained nightly branch, because when the language catches up, the fix is mechanical.

On the operator set, the contributing section points at missing_features.md as the place to see what operators are needed. That file is the honest measure of the library's coverage, and reading it before you commit is the single most useful thing you can do, because a reactive library is only as good as the operators it has.

The other contributing guidance is standard and worth repeating, because it tells you what the maintainers want. Look for Good First Issue tags on GitHub, and improve documentation or add examples.

The repository layout supports the picture. There is src/ for the implementation, tests/ and examples/, with examples/custom_scheduler.rs as the one example file, which corresponds directly to the custom scheduler option the documentation mentions. There is guide/ with the getting started guide and the core concepts and advanced architecture documents, macros/ for the macro crate, configs/ for the tool configuration, a .cargo/ directory, a rust-toolchain.toml, a rustfmt.toml, and a CHANGELOG.md.

The maintenance badge in the Cargo.toml says actively-developed, and the badge list also includes codecov, dependabot, docsrs with all-features set to true so the documentation builds with the nightly feature included, and a GitHub Actions badge. The docs.rs configuration with all-features = true is a detail that matters, because it means the published documentation includes the nightly path rather than hiding it, and for a library where the nightly feature changes the signature of map, that is the difference between a user finding out and not.

Editorial conclusion

rxRust fits a Rust developer who wants a stream abstraction with typed operators and needs the same logic to run on a UI thread and across worker threads, and who is willing to work with a 1.0 release candidate. It is the wrong choice if you want the widest operator set, since the project maintains a missing features list, and the wrong choice if you need a stable published release rather than an rc. Before adopting, check missing_features.md against the operators your code needs, pin the exact rc version, and read the scheduler section, because the default schedulers depend on tokio and the wasm target uses gloo timers and wasm bindgen futures instead.

Frequently asked questions

What is rxRust and what is the Local versus Shared distinction?

rxRust is a zero-cost, type-safe Rust implementation of Reactive Extensions. Local uses Rc and RefCell with no locking, and the compiler prevents those types from leaking across threads, which suits UI threads, WASM and single-threaded event loops. Shared uses Arc and Mutex and is required when streams cross threads or share state globally. The same operator chains work in both.

How does rxRust handle scheduling and timing?

By default the schedulers rely on tokio, and you can disable the default scheduler feature to implement a custom scheduler for your own runtime. The library provides timer and interval sources, and operators such as throttle_time with an explicit ThrottleEdge. Local context schedulers require running within a LocalSet or a compatible LocalRuntime.

How do I multicast events with a Subject in rxRust?

Create it through the context, for example Local::subject(), then subscribe each observer to a clone of the subject and push values with next. The Subject type automatically adapts its internal storage, Rc<RefCell<...>> or Arc<Mutex<...>>, to the context it was created in, so the locking cost follows from your choice rather than being a separate decision.

Is rxRust stable or does it need nightly Rust?

It targets stable Rust by default, and the crate is currently a 1.0.0 release candidate. A nightly feature exists for lifetime-dependent mapped outputs such as &'a mut T -> &'a U, enabled with features = ["nightly"] and built with cargo +nightly test --features nightly, and the project says it will expand that support once the relevant language support is mature.

Official sources

  1. Issues
  2. License: MIT
  3. Project website
  4. README
  5. rxRust/rxRust on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/rxrust-rxrust.svg)](https://hysenlabs.com/projects/rxrust-rxrust)

Was this useful?