Why building a front-end framework in Rust is hard
Rust is an incredible language. It is fast, strongly-typed, and its developer experience and community are some of the best. Rust is also one of the few languages that can compile to WebAssembly (WASM), which means we can write Rust code that runs in modern browsers! This opens up the possibility of a front end framework written in Rust.
Why do we want a Rust front-end framework?
A Rust front-end framework offers several benefits:
- Correctness thanks to strong typing
- Ability to share code with Rust backend
- Performance (if done right)
- Ability to run it in a separate thread/worker to prevent blocking the UI thread
This framework could be platform-agnostic, fulfilling the ideal of “write once, run anywhere”, a concept that Java heavily promoted in the 1990s. Personally, this is the main reason I want a Rust front-end framework. Many people spend countless hours rewriting UIs for different platforms.
Challenges
WebAssembly limitations
WebAssembly is designed as a simple binary instruction format, which means it lacks many of the built-in methods and primitives that JavaScript developers take for granted, such as JSON.stringify
or RegExp
. When working with WebAssembly, you have two main options:
- Implement the functionality in Rust - This results in larger WASM files
- Use the WebAssembly JavaScript API to call into JavaScript
In the second case, communication between WebAssembly and JavaScript is limited to binary formats, as the WebAssembly Component Model is still being finalized.
In addition, JavaScript uses UTF-16 while Rust uses UTF-8. This means any string operations between the two languages require conversion between formats1, introducing additional overhead. Or you can eat the cost by using a special string type in your front end application.
Rust ownership and graphs
Rust’s ownership model is a unique concept that enables safe concurrent code by ensuring each reference has exactly one owner at any time. For a deeper understanding, refer to the Rust Book’s explanation on ownership.
This ownership constraint means that data structures in Rust naturally form trees, where ownership relationships are clear and hierarchical. Data structures like doubly-linked lists and graphs, which have multiple references pointing to the same data, are much more challenging to implement. They typically require advanced concepts like interior mutability or unsafe Rust code.
Current Rust front-end frameworks like Yew and Seed work around this by adopting The Elm Architecture (TEA), which was the inspiration for Redux in the JavaScript world. TEA enforces a strict top-down tree structure for managing components and state.
While this tree-like structure aligns well with Rust’s ownership model, it comes with trade-offs. State must flow from top to bottom, and subtrees need to perform state diffing to avoid unnecessary re-renders. This diffing process takes O(log n) time, becoming less efficient as applications grow. Additionally, state management becomes more cumbersome since TEA doesn’t support local component state - everything must flow through the global state tree. This goes against modern JavaScript best practices, which favor more localized state management.
For optimal performance, we’d want O(1) state diffing through a reactivity system like those used in Vue and Svelte. However, reactivity systems are inherently graph-based - they consist of state nodes and effect nodes that need to reference each other. States need to know which effects depend on them to trigger re-computation, while effects need to know which states to observe.
This presents the core challenge: implementing an ergonomic reactivity system requires managing a graph structure, which conflicts with Rust’s ownership model. This is why frameworks like Leptos resort to using slotmap to manage their graph structure. Slotmap effectively implements a handle-based system for managing references, working around Rust’s ownership constraints.
Using a handle-based system is a pragmatic solution, but it comes with its own set of trade-offs - debugging is harder, and memory safety is reduced as cyclic references are possible.
Rust ergonomics
Although Rust is an amazing language for many things, it does have some warts of its own.
Optional properties
One of the most common parts of writing front-end code is passing down props to a child component. In Rust, there isn’t a way to ignore optional properties.
impl Component for MyButton { pub fn render() { Button { textContent: "Click Me", style: None, class: None, onClick: None, onHover: None, onBlur: None // etc... } } }
To work around these limitations, Rust libraries typically choose between two approaches:
- The builder pattern - This provides good IDE support with auto-completion, but requires defining many extra functions, leading to code bloat.
- Macros - This is terser but prevents auto-completion from working.
Passing down properties
Component-based architecture requires components to pass down properties to their child components. In JavaScript, you can pick which properties to pass down using the spread operator:
type ButtonProps { name: string onClick: () => void } type ButtonContainerProps = { ...ButtonProps style?: string class?: string // etc... }
In Rust, we have to use traits, composition, or macros:
Traits
This requires defining many extra functions, which is verbose, and the orphan rule makes it difficult to share components.
use button::ButtonProps; struct ButtonContainerProps { name: String, onClick: OnClickHandler, style: Option<String>, class: Option<String>, // etc... } impl ButtonProps for ButtonContainerProps { fn name(&self) -> String { self.name } fn onClick(&self) -> OnClickHandler { self.onClick } }
Composition
struct ButtonContainerProps { buttonProps: ButtonProps, style: Option<String>, class: Option<String>, // etc... }
Arguments can get out of hand after 3 levels:
let modalProps = ModalProps { buttonBarProps: ButtonBarProps { buttonProps: ButtonProps { name: "Click Me", onClick: () => {} } } }
Macros
Arguably the best solution, but it isn’t “rusty”, and could be complicated with generics, builder patterns, and other features.
#[derive(Extend(ButtonProps))] struct ButtonContainerProps { style: Option<String>, class: Option<String>, // etc... }
Rust’s code bloat
While Rust is very well suited for system programming, its foray into embedded systems has only just started. As a result of earlier design choices, Rust has a rather big standard library.
When we try to compile Rust to WebAssembly file formats (.wasm), just the “hello world” example’s size is in the tens of kilobytes. The most popular Rust front-end framework, Yew, results in wasm files that start from 300kB onwards. This may look acceptably small, but compared to JavaScript front-end libraries like Preact (17kB) and Svelte (4kB), it is gigantic.
On the optimistic side, Rust is still a young language, and many of these code bloat issues are currently being tackled by the embedded working group:
Conclusion
These are the issues that are preventing an easy-to-use Rust front-end framework from being written. However, the Rust and WebAssembly scenes are burgeoning, and given enough time, improvements will come. But don’t wait, now is the perfect time to start learning Rust and WebAssembly before it becomes the next big thing!