NeoView: Robust Reactive UI
Rust is a unique language. It is remarkable how a critical systems programming language features extreme ergonomics and expressivity that rival that of scripting languages. These traits have made Rust the most desired language for low-level domains, and recently it has started expanding into higher-level applications, especially web frontends.
Rust features notable declarative, reactive UI frameworks (Dioxus, Leptos, Yew, Sycamore…) that, inspired by JavaScript frameworks, have worked to port their paradigms and concepts while adding a Rusty flavor. They have absolutely succeeded, until you look inside.
If you glimpse inside the internals of these frameworks, you will see a world of Rc<RefCell<T>> powering every primitive. Furthermore, there is the possibility of dangling signals. Yes, under the hood of these declarative masterpieces lie extreme architectural compromises.
Please note that these frameworks work as intended. Their goal is to provide extremely ergonomic declarativity coupled with deep reactivity. Their codebases are highly optimized for this, as they target the web, not OS kernels. Additionally, dangling signals are rare edge cases that most developers will never encounter.
Yet we are using Rust. We develop our codebases to be robust and performant, not just to meet requirements, but because we can and because it is great. And If you don’t believe it, we can reframe their ergonomics in a more robust, performant, and Rusty way. That is exactly what NeoView does.
NeoView Principles
Section titled “NeoView Principles”Before diving into the technical decisions NeoView has made, let’s take a broad look at its philosophy.
NeoView is a lightweight, declarative, reactive UI framework aligned with Rust’s core principles rather than trying to sidestep them. It is a middle ground, providing ergonomic declarative UI definitions while heavily prioritizing robustness, efficiency, and a Rusty feel.
NeoView seeks an equilibrium between opposing forces. It doesn’t sacrifice its lightweight spirit for the sake of saving a few keystrokes, nor does it violate reactivity principles for bare-metal performance. It is engineered for deep, unrestricted reactivity that costs no more than shallow collection access and slight runtime dispatch.
NeoView achieves this by being slightly more explicit. It borrows old-school concepts and merges them with modern trends, guided by the Rust engineering spirit: high-level ergonomics built upon low-level masterpieces. The result is a declarative, reactive, yet robust and lightweight UI definition with minimal signal noise, on the scale of Rust developers.
NeoView doesn’t try to be a fully fledged rendering engine. Instead, it is a family of renderer adapters that share the same reactive primitives, templating language, and core philosophy, with each having its own flavor regarding rendering primitives, implementation, and supported platforms.
In short, NeoView is a thin reactive layer for app logic that sits on top of existing rendering engines, giving low-level developers the ergonomics of high-level frameworks in a flavor they adore.
While it might seem overengineered for production, NeoView is a solo side project. It is more of a proof of concept and prototype than battle-ready infrastructure. It is overengineered out of habit and intrinsic curiosity, built primarily for fun.
Having laid the groundwork, let’s move on to the UI’s outlook.
Templating Style
Section titled “Templating Style”The countless variations of templating syntaxes and styles featured in Rust UI crates generally consist of two main families: vanilla and macro-based.
The vanilla family uses native Rust syntax in UI definitions. They offer a simpler learning curve, beautiful vanilla vibes, and the expressivity and comfort of the language itself. They coexist incredibly well with the rest of the Rust world since they are a natural part of it.
However, if your UI primitives have slightly more diverse arguments, you will quickly hit the wall of Rust’s explicitness principle. Regular functions and structs become too limiting, leading you to abuse tuples as heterogeneous lists, and rely on generic builder madness or type constructors depending on your personal taste.
// This simple Xilem counterfn app_logic(data: &mut Counter) -> impl WidgetView<Counter> + use<> { flex(Axis::Vertical, ( label(format!("{}", data.num)), text_button("increment", |data: &mut Counter| data.num += 1), ))}
// Its return type is this monstrosityFlex<(Label, Button<impl Fn(&mut Counter), Label>), Counter>
// And you can imagine what a complex component would look likeOn the other hand, there is the macro-based style, where you use the power of macros to hide UI implementation details under whatever beautiful syntax you can dream up. Macros are expressive and highly ergonomic, making them the go-to choice for the majority of frameworks.
Macros have one major weakness: their non-standard syntax causes generic tooling to give up. Although through span coloring tech we can achieve excellent IntelliSense with syntax highlighting, hover descriptions, go to implementation, and fully customizable completions, still formatting must be implemented manually.
// Use modules and init structs as meta itemsmod tags { /// [`div`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/div) html element pub struct div; // ...}
macro_rules! el { ($tag:ident $($t:tt)*) => {{ // This no-op is the magic tags::$tag; let el = __runtime::create_el(stringify!($tag)); // ... }}}
fn example () { // `div` has unexpected IntelliSense el!(div, /* .. */)}NeoView, acting as a middle ground, provides both. The macro style is the primary one, as it is less symbolically verbose, it is object-styled to better align with Rust’s overall aesthetics. It also provides a type-constructor native style, primarily intended for utility snippets. You can use whatever you like, however you like.
chunk!(build, div { h1 { "hello world" } input(r#type: "text") p(style.color: "red") { "i am a content" }});
/// Focus the element based on a reactive propertyfn focus(prop: PropId<bool>) -> impl Applicable { move |build: &mut ChunkBuild| build.ref_el(move |ctx, el| { // Just mild web_sys verbosity, and don't ever try to construct an element. let el = el.clone().dyn_into::<HtmlElement>().unwrap(); ctx.effect(move |ctx| if ctx.get(prop) { el.focus().unwrap() }); });}build.apply(focus(show))
// What is `build`? That is the twist.Better Fit Reactivity Patterns
Section titled “Better Fit Reactivity Patterns”Wrong Primitives
Section titled “Wrong Primitives”If you dive into it, you can trace almost all the overengineering that has occurred in modern reactive systems back to a single primitive: signals.
Signals in their own right aren’t bad, they are practically miracles. They are reactivity wrappers with no restrictions on transportation and storage. Their sheer freedom and minimal syntax are what created the huge ergonomics of today’s reactive UI frameworks. However, their main selling point is also their fatal flaw.
We are working in a language where memory safety and a clear ownership model are its very identity, yet we are trying to force in primitives where that constraint is against their very spirit. How can you satisfy the borrow checker with a nomadic, inherently unbounded value using sane methods?
The mechanisms developed by Rustaceans for this dilemma generally fall into two camps: naively dumping everything into Rc<RefCell> and calling it a day, or engineering a global dispatcher that dumps state into thread-local arenas so that signals become lightweight, Copyable IDs.
While the second approach is actually incredibly great and achieves an elegant solution to the problem, it has visible edge cases. Signals are not pointers, but they are indexes that can dangle if their UI scope is dropped. While this can be mitigated with explicit scopes and owners, signals remain unbounded values that can be used at any time, triggering safety panics upon use-after-free, the exact thing Rust was born to eliminate.
The proposed solution? Just throw away signals and use the old-school collection access pattern.
Collection Access Pattern
Section titled “Collection Access Pattern”Multiple thoughts might come to mind: “What?”, “How?”, “Isn’t this against the principle of ergonomics?” To answer these questions, let’s first discuss what the collection access pattern actually is.
The collection access pattern means writing store.read(prop) instead of prop.read(). This quickly answers the ergonomics violation argument: it is just an additional store. at every access site. Also, this is Rust, a language even more explicit than C, these five extra characters are nothing compared to the other monstrosities we regularly deal with, yet their absence is the root of the entire crisis.
This approach is exactly like the arena-based reactive system, with the single exception that the reactive system is passed explicitly. Now, the issue has been brought back to ground-level Rust, the borrow checker can enforce its laws, and the overengineered systems can be reduced to regular collections.
The Store is passed by mutable reference. Yes, a deep and unrestricted reactive system can be and is built on this limitation. Everything just takes its turn. Exactly like how you pass with you a collection or context and read or mutate it on the fly, chaotic state access simply needs to be tamed.
The fine-grained reactivity system is essentially just a collection, not an alien contraption. Though, it’s a super collection that holds heterogeneous values, tracks access, and dispatches updates when it is free, plus it contains metadata and other items like effects. At the end of the day, it behaves just like any regular collection type.
let a = store.prop(1);let b = store.prop(String::from("abc"));let c = store.prop(true);
// You can reference multiple properties togetherprintln!("{} {} {}", store.read(a), store.read(b), store.read(c));
// You can write to exactly one property as long as there are no active referencesstore.write(a, 2);
// Don't worry, just enforce the read-compute-write patternstore.write(b, format!("{}: {}", store.read(b), store.read(a)));
// Just throw this patternif false { let a_value = store.read(a); store.write(c, *a_value == 2); store.write(b, a_value.to_string());}
// You also have convenience methodslet _: u64 = store.get(a);*store.read_mut(a) += 1;store.update(c, |v| *v ^= true);
// These patterns cover 99.99% of cases.// However, if you really need to mutate multiple properties at the same timelet (a_value, b_value) = store.read_disjoint_mut((a, b));*a_value += 1;b_value.push_str("def");As you can see, accessing properties is totally fine. As for how the store is obtained, you simply pass it along. We love to praise the flexible and asynchronous nature of our reactivity systems, but virtually all well-architected UIs have a predictable, synchronous logic flow triggered by events. Passing the store is just providing an extra identifier at each call site, with no lifetime madness since properties are just IDs.
The UI system will pass this store to your code during creation, upon events, and even inside effects. We already track updated properties and dispatch them as a single batch for performance reasons, so effect execution is a predictable function call, not an inter-access chore. Yes, even the reactivity system takes its turn in state access.
let count = store.prop(0);store.effect(|ctx| println!("count: {}", ctx.get(count)));Wait a minute, what is ctx? Nothing, just the entire UI system. We are already passing the entire reactivity system around, so passing the UI system itself is naturally the next step.
Context Passing
Section titled “Context Passing”While this might sound absurd at first, it is identical to passing the store, just more comprehensive. We used to do this in the old days with immediate-mode GUIs. All the patterns discussed above apply here as well. It is still an identifier passed around to all UI creation and state access sites, moving to and from API functions, user functions, and effects.
Rest assured, the most finicky part is state access, and it remains highly ergonomic. In addition, all common reactivity methods are redirected by every context type through StoreProv. The remaining UI tasks such as construction and binding creation are mostly push-based, and most inputs come in owned types, just like events.
// Notice any significant difference?
fn rest_counter (name: &str) -> impl View { let count = signal(0); effect(move || println!("count: {}", count.read())); view! { button { on.click: move || count.update(|v| *v += 1), name, ": ", count } }}
fn neoview_counter (build: &mut ChunkBuild, name: &str) { let count = build.prop(0); build.effect(move |ctx| println!("count: {}", ctx.get(count))); chunk!(build, button ( on.click: (move |ctx, _| ctx.update(count, |v| *v += 1)), ) { name, ": ", count });}Much like what happened with state access, the UI system gains an explicit lifetime. While most declarative UI frameworks give the impression of supporting multiple UI instances, they are not truly independent because their surface API exposes a global runtime. In NeoView, UI systems are completely isolated with strictly enforced lifetimes and boundaries.
Being explicit introduces a benefit that is often overlooked by the majority of developers, and is the source of all Rust guarantees, all ownership responsibility falls onto a single entity. Here, that entity is the context, and it is fully equipped for the battle.
The context is not directly owned, instead, access is regulated by locks and guards to ensure only one mutator is active at a time. This guarantees a relaxed developer experience within synchronous bubbles created inside the chaotic nature of app flow.
// You write thisfn some_logic(ctx: &mut Context, data: SomeData) { if ctx.read(some_state) { ctx.write(other_state, data); }}
// And we provide you withon_external_event(move |data| handle.use_ctx(move |ctx| some_logic(ctx, data)));
// Even async is typicalfn async_logic(ctx: &mut Context) { let handle = ctx.new_handle(); while let Some(content) = some_stream.next().await { // Wait for context since it may be used let mut ctx = handle.acquire().await; ctx.update(streamed_state, |s| s.push(content)); }}In addition, being explicit and explicitly mutable drastically simplifies the internals to the point they turn into humble utilities. A simple Ctrl + Click takes you straight to the beating heart of the framework. Instead of ginormous systems with hyper-engineered mechanisms, you will find bare, straightforward, yet highly effective primitives.
Now the reactivity system is fully resolved: it is explicit, offers stronger guarantees, and has much simpler internals. Now to explain the mysterious ChunkBuild, let’s first question the very identity of templating itself.
Chunked Construction
Section titled “Chunked Construction”Why UI as a Value?
Section titled “Why UI as a Value?”Let’s begin with an existential question: why should the UI structures be returned as values?
If we look back in history, the biggest milestone in declarative UI was and still is inlining templates directly inside the host programming language. The transformation of templates from alien DSL blobs into regular language constructs that behave and live like any ordinary piece of code.
Therefore, treating UI as value expressions was the natural way to go, especially in expression-based languages like Rust. The UI definition just declares the structure and its bindings, then harness the language’s native flow and existing mental models by simply returning a value.
// Different species, same semantics.let cond = true;let list = vec!["a", "b", "c"];
let sub = "sub";format!("{} {} {sub}", if cond { "true" } else { "false" }, list.iter().enumerate().map(|(i, item)| format!("{i}: {item}")),);
let sub = view! { "sub" };view! { if cond { "true" } else { "false" }, list.iter().enumerate().map(|(i, item)| view! { format!("{i}: {item}") }), sub}Everyone admits that this combination has served us exponentially well, especially with powerful patterns like the match expression, the iterator paradigm, and the lesser-known expression block. However, try to create structures that are more than three levels deep, and syntax nightmares quickly emerge from this masterpiece of expressivity.
fn indentation_to_infinity_and_beyond (list: &[&str]) -> impl View { view! { ol { if list.is_empty() { "no items" } else { list.iter().enumerate().map(|(i, item)| view! { li { format!("{i}: {item}") } }) } } }}The universally accepted solution to this problem is either accepting heavy word-wrapping indentation, or relying on OOP-inspired delegation into a gazillion nano-components. NeoView’s answer to this is freeing the UI using the builder pattern.
Builder Pattern
Section titled “Builder Pattern”The builder pattern is a common paradigm in Rust. It refers to any pattern that relies on the implicit construction of structures through an imperative API. This is the opposite of object composition, where the structure is built explicitly using object initialization syntax.
NeoView doesn’t utilize the builder pattern for individual element creation, the typical use in the ecosystem. Instead, it leverages the builder pattern in the truest sense of its name: as an actual, imperative constructor for the entire UI structure.
// Builder patternlet mut buf = vec![];buf.push('a');for i in 0..3 { buf.push('b'); }if (some_cond) { buf.push('c'); }let seq = buf.iter().collect::<String>();
// Object compositionlet seq = build([one('a'), repeat('b', 3), cond(some_cond, 'c')])Absolutely, it looks verbose and completely unergonomic. However, it scales incredibly well and is extremely consistent, which is the most important thing. And if you look closely, you might notice its power. But it still inexpressive enough on its own, so with object composition it get merged to unlock unexpected potential.
The template primitive in NeoView behaves exactly like those in other fine-grained, reactive declarative frameworks. The chunk! macro takes UI definitions written in a declarative, object-style syntax with heterogeneous arguments and implicit bindings. The twist, the constructed UI is not returned, rather, it is pushed into an internal overarching structure.
let count = build.prop(0);build.effect(move |ctx| println!("count: {}", ctx.get(count)));chunk!(build, button ( on.click: (move |ctx, _| ctx.update(count, |v| *v += 1))) { "counter: ", count });
let text = build.prop("");chunk!(build, input(on.input: (move |ctx, evt| { let el = evt.target().unwrap().dyn_into::<HtmlInputElement>().unwrap(); ctx.write(text, el.value()); })) div { "text: ", text });This primitive example sums up NeoView pretty well. It takes the render context / draw call API from graphics programming and fuses it with the expressive UI definitions of declarative frameworks. By merging the teachings of two schools of thought on opposing edges, it creates a unique harmony between imperative and declarative paradigms, alongside underdiscovered surfaces.
The Uniqueness of Chunked Construction
Section titled “The Uniqueness of Chunked Construction”NeoView calls this approach chunked construction, and ChunkBuild is the builder that powers it. The ChunkBuild borrows the context, redirects the reactivity primitives through StoreProv, and gives back context ownership when it finishes.
The ideology behind chunked construction might seem completely unrelated to UI as values at all. In fact, they are very alike, both harness expressivity by using a flowing value. In chunk-oriented construction, that flowing value is the builder itself, and this distinction is the source of all its uniqueness.
The primary benefit of chunk-oriented construction is the unrestricted localization of logic and structure. Since the UI is composed of multiple chunks, there is no requirement for strict “logic first, then UI” code blobs. You can interleave them naturally. In fact, it is considered best practice for each UI section to define its logic locally around itself, as seen in the previous examples.
Being imperative draw calls rather than type constructors, chunk! calls can be used inside any native control flow, and even inside any imperative pattern. Forget about mandatory else blocks and overly long iterator chains, embrace native flow expressions like if, for, match, and even while and loop.
for i in 0..12 { let count = build.prop(0); chunk!(build, button ( on.click: (move |ctx, _| ctx.update(count, |v| *v += 1)) ) { "count ", i, ": ", count }
// Control flow can be used directly inside a chunk, but only for structure match i % 6 { 2 => br(), 5 => hr(), _ => {} } );}// Note that these control flows are static, not reactiveThere are certain things in nature that, once you know them, you can’t live without. Expression blocks are one of them. In Rust, expression blocks are statement blocks used as expressions, letting you insert tiny, local, multi-statement snippets directly between other expressions. Every Rustacean adores them, and naturally, NeoView supports them.
do blocks are code blocks executed at their point of definition inside the structure. Inside them, chunk! calls target that exact insertion point. This transforms the chunk build from a simple linear builder into a tree builder where chunks can be nested indefinitely, achieving incredibly deep logic-structure localization. Just don’t abuse them and conform to standard coding style conventions.
chunk!(build, div { h1 { "counter" } do { let count = build.prop(0); chunk!(build, button ( on.click: (move |ctx, _| ctx.update(count, |v| *v += 1)) ) { "count: ", count }); chunk!(build, div { "count * 2 = ", move |ctx| ctx.get(count) * 2, do { build.apply(show_if(move |ctx| ctx.get(count) > 0)) } }); }});Components are the foundational building blocks of most UI frameworks, literally the only blocks. Almost every solution inside these frameworks is a component: primitive UI constructs (dynamic flows, error boundaries), utility wrappers, design patterns, and even state providers are also components for some reason.
Components were initially marketed as an abstract concept, but due to implementations, they became the only available scoping mechanism, inevitably leading to their abuse.
In NeoView, there are no components, since there is no need for specific framework constructs for them. Nano-components and utility components can just be do blocks or simple utility functions, while larger, fully-fledged components are just regular functions that borrow the chunk build.
fn counter(build: &mut ChunkBuild, name: char) { let count = build.prop(0); chunk!(build, button ( on.click: (move |ctx, _| ctx.update(count, |v| *v += 1)) ) { name, ": ", count });}
counter(build, 'a');chunk!(build, div { for name in 'b'..='g' { do { counter(build, name) } }})Unparalleled Flexibility
Section titled “Unparalleled Flexibility”These features might seem unique to chunk-oriented construction, but in reality, the UI-as-value pattern can achieve most of them effortlessly, especially isolated chunks and expression blocks. Semantically, there are no restrictions preventing them, however, the strict “logic first, then structure” principle is rooted deeply within the community’s mindset.
fn example () -> impl View { let state_a = signal(0); let section_a = view!{ button { onclick: move || state_a.update(|v| *v += 1), "a: ", state_a } }
let state_b = signal(0); set_interval(move || state_b.update(|v| *v += 1), 1000); let section_b = view!{ "b: ", state_b }
view! { section_a, section_b, // Actually this is done by ripple.ts though statement containers, and it's the thing that motivated me when I saw it { let state_c = computed(move || state_a.read() + state_b.read()); view! { "a + b: ", state_c } } }}The UI-as-value pattern can achieve these patterns with slightly more verbosity, but these aren’t even chunked construction’s biggest capabilities. The UI-as-value pattern has an existential curse: it must be returned by every component. This locks it into a very restrictive, synchronous flow. In NeoView, chunks are spatially and temporally isolated.
chunk builds being the flowing primitive lead to the usability of every possible imperative pattern. Not just standard control flows, but every pattern you can dream of: synchronous, or inside effects, asynchronous, concurrent, and even recursive. The only limitation, an active mutable reference to the context.
While that limitation might seem highly restrictive, one build can handle any static structure no matter how ginormous it is. Moreover, the context is implicitly borrowed from the chunk build on every reactive access, exactly like any ordinary field inside a mutably borrowed struct.
This fact enables the creation of multiple isolated chunk builders across the call stack, with each finishing before handing control back to the higher-up build. This is the core mechanism that drives many built-in utilities, such as render_list.
// Chunk builds generally inherit the global scope,// but can have their own reactive scope if they get removed.let removable = build.ctx().removable_chunk("div");counter(&mut removable);let (el, remover) = removable.build();let mut remover = Some(remover);chunk!(build, el, button( on.click: (move |ctx, _| if let Some(remover) = remover.take() { remover.remove() })) { "remove counter" });This pattern solves the majority of synchronous cases. Still, there cannot be multiple active chunks at the same time, since only one context mutator can be alive, which is a requirement for async rendering. While other frameworks might opt for internal mutability and sophisticated internals to allow concurrent chunking, NeoView solves the problem without you even realizing it.
The problem is how to keep the ChunkBuild alive. Just like with state stop fighting it, split your concerns and delegate ownership troubles to the already equipped context. Through acquire, we already have async support in native syntax, just create a new UI chunk for every stream chunk.
// All advanced frameworks support async rendering in some form.// However, is it a regular async function?async fn async_content (parent: &mut ChunkBuild) { let mut build = parent.ctx().new_chunk_tagged("div"); chunk!(build, "async content"); let el = build.build(); chunk!(parent, &el);
while let Some(content) = content_stream.next().await { let ctx = handle.acquire().await; let mut build = ctx.new_chunk(el); chunk!(build, content); build.build(); }}For even more perfection, chunk builds can hibernate, returning the context and their dormant state, which can be reactivated at any time to continue from where left off. Though I haven’t found a common use case for it yet, I initially created it for advanced scenarios, in most situations, it is a better and simpler approach to just split the chunk build.
Flexible Internals
Section titled “Flexible Internals”In addition to the architectural flexibility NeoView provides to users, it also offers immense flexibility for renderer implementors.
In the UI-as-value pattern, UI definitions return structural values. This practically requires your rendering engine to be element-object-oriented. If it isn’t, the entire app logic will descend from the stack to be rendered at the main function.
// Plain intermediary structs:// Acceptable runtime overhead, easy implementation.struct Element { tag: String, attrs: Vec<Attr> children: Vec<Child>,}
// Type-encoded structure:// Near-metal performance, mad-level implementation complexity, monomorphization engine prays on you.struct Element<T: TagName, A: TupleList<Attr>, C: TupleList<Child>>;
// Counter typeElement<Button, (Event<Click>)), (StaticText, SignalText)>NeoView takes the opposite approach. The chunk build remains in place while a pointer to it travels around the application. The chunk! macro writes directly into the inner structure without any intermediary allocations or mediums. There are no restrictions as it is just direct calls, so your rendering engine flavor is the chunk build internals.
Implementation freedom is a core part of NeoView’s thin driving layer over primitive beast identity, and chunked construction compliment that by supporting the full spectrum of rendering paradigms. The underlying primitives can range from element objects to direct draw calls. If it behaves like a chunk, it is a chunk.
While the current selection of implemented renderers is tiny (only one), it is enough to demonstrate this versatility. In NeoView’s web renderer, UI definitions turn into buffers of bytecode executed on the JavaScript side. Since the chunk! macro compiles into a series of calls, the code generator takes these local, static, shallow calls and turns them into minimal memory stores, achieving optimal efficiency inside the WebAssembly boundary.
Just One Identifier
Section titled “Just One Identifier”All this time, we have been passing around a single identifier. It evolved with every section, gathering more responsibilities until it encompassed the entire UI family. However, did you ever feel the entire app’s UI under its weight? No. It was a light traveler moving through your codebase, linking all the UI logic with explicit links, drastically enhancing the UI runtime, and making it more robust and Rusty. It was just one identifier. So why did its species get buried?
why we ignored traditions and reached for extreme ergonomics, the characters we removed didn’t vanish. They just moved under the stage, into infrastructures and runtimes that became incredibly sophisticated. NeoView is a callback to the true nature of evolution: enhancing old methods with new concepts to better adapt to the habitat. So, let’s just bring back that identifier.