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 brings the following 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

The framework could be platform agnostic, achieving the holy grail of "write once run anywhere" that Java so heavily touted in the 1990s. Personally, this is the biggest reason, as people spend countless hours rewriting UIs for a different platform.

There are disadvantages to a Rust front-end framework, mostly due to the limitations of WebAssembly. WebAssembly is a simple binary instruction format, so the file size tends to be bigger than JavaScript files, which means they take longer to download. Additionally, the WebAssembly Interface Types specification is not finalized, which means we can only communicate with JavaScript via binary at the moment.

What makes a Rust front-end framework hard

Rust ownership and graphs

Rust has a unique concept known as ownership, which allows safe concurrent code by ensuring that there is only one owner for a reference at any point in time. The Rust Book's explanation on ownership goes much deeper into the subject.

As a result of this ownership constraint, data ownership structures in Rust are typically trees. Data structures like doubly-linked lists and graphs where there isn't a clear owner are much harder to write. They require the knowledge and usage of interior mutability or unsafe Rust.

Existing front-end frameworks like Yew and Seed adopt The Elm Architecture (TEA), which is a top-to-bottom, tree-like way of managing components and state. For those more familiar with JavaScript, TEA was the inspiration for Redux.

This tree-like structure is what Rust prefers, with the trade-off that the state must flow from the top-to-bottom. Each time, subtrees must diff the state to avoid re-rendering, which means that the state diffing logic takes O(log n) time. As the application grows bigger, state management becomes more cumbersome and less efficient. The more recent sentiment in the JavaScript community is that Redux should not be in charge of all states. TEA goes against this as there is no concept of local state.

If we want to achieve the most efficient O(1) way of diffing state, we would require a reactivity system similar to the ones used by Vue and Svelte. Unfortunately for us, reactivity systems are graphs. A reactivity system comprises of state and effects. Effects need references to state to do some computation, and state needs references to effects to inform them to re-compute.

This is the biggest hurdle. In order to introduce an ergonomic reactivity system, it seems we would need a special memory allocator, like a slab allocator with garbage collection to avoid Rust's ownership issues.

Rust ergonomics

Although Rust is an amazing language for many things, it does have some warts of its own. 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...
        }
    }
}

Due to this limitation, Rust libraries resort to either the builder pattern or macros. The builder pattern allows auto-completion but results in code bloat due to all the extra function definitions. Using macros, on the other hand, is terser but prevents auto-completion from working.

Another limitation is Rust's inability to share common properties between structs, deferring the responsibility to traits instead. While it works well for most programming, component-based architecture requires components to pass down properties to their child components.

struct ButtonProps {
    name: String,
    onClick: OnClickHandler,
}

struct ButtonContainer {
    ..ButtonProps // can't do this
}

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!