Introducing Kobold

Kobold logo

Kobold is a crate for creating declarative web UI.

The TodoMVC implemented in Kobold weighs about 30kb when gzipped over the wire, out of which the gzipped Wasm blob is under 20kb. There are no tricks there. I haven't replaced the default allocator, nor have I minified the JavaScript (although I probably should).

The goal I have set for myself was creating something that works and feels like a yet another JSX-esque library akin to React or Yew, but without the full performance overhead of Virtual DOM.

All of it in Rust, of course.

The documentation combined with the examples should give you some insight into building things. What I would like to do here instead is lay down some context, explain how I think about this problem space and how Kobold actually works.

Zero-Cost Static HTML

On the surface Kobold works exactly like a Virtual-DOM-based library. There are no extra containers to wrap your data in and it does indeed perform the dreaded diffing, however the only thing that ever gets diffed is your data.

I first started experimenting in December of 2019 with a procedural macro that would eventually become the view! macro in Kobold. I didn't think about components, or data states, event handling, or even rendering lists. All I wanted is something that looked like JSX, but instead of producing Virtual DOM it would produce pre-compiled JavaScript code to manage all the stuff in the DOM that never changes.

Ignoring components for now, the absolute simplest piece of code we could do with Kobold would look something like this:

fn hello() -> impl View {
    view! {
        <h1>"Hello world!"</h1>
    }
}

If you have ever worked with Yew or React this should immediately look familiar. The main difference between Kobold and Yew here is the opaque impl View return type. The View trait definition looks as follows:

/// Trait that describes types that can be rendered in the DOM.
pub trait View {
    /// The product should contain a DOM reference to this View and
    /// any data it needs to update itself.
    type Product: Mountable;

    /// Build a product that can be mounted in the DOM from this type.
    fn build(self) -> Self::Product;

    /// Update the product and apply changes to the DOM if necessary.
    fn update(self, p: &mut Self::Product);

    /// ... skipping some methods here that are auto provided
}

Any type that implements View needs to know how to build its Product and how to update it on subsequent renders. Said Product will contain a reference to its root DOM element plus any data it might need to diff for updates. The important part is: you never have to write these two methods by hand.

If we expand the view! macro our hello function becomes:

fn hello() -> impl View {
    #[wasm_bindgen(inline_js = "<snip>")]
    extern "C" {
        fn __e0_dd8ebbc530e4055f() -> web_sys::Node;
    }
    Static(__e0_dd8ebbc530e4055f)
}

I have taken the liberty of formatting the output while also not expanding the #[wasm_bindgen] attribute macro here. The snipped JavaScript is:

export function __e0_dd8ebbc530e4055f() {
    let e0=document.createElement("h1");
    e0.append("Hello world!");
    return e0;
}

The Static is just a newtype wrapper. Here is its declaration and View implementation:

pub struct Static<F>(pub F);

impl<F> View for Static<F>
where
    F: Fn() -> Node,
{
    type Product = Element;

    fn build(self) -> Element {
        Element::new(self.0())
    }

    fn update(self, _: &mut Element) {}
}

Two things of note:

  1. Static<F> taking function as a generic parameter will have std::mem::size_of::<Static<_>>() == 0. This means that on runtime calling hello() does quite literally nothing, and only hello().build() calls the extern JavaScript function that constructs the <h1> header and gives us the reference to its root element.
  2. Since there are no expressions here, the update method is also empty, meaning that invoking hello().update(&mut product) also does absolutely nothing at runtime.

Herein lies the crux of the #1 claim that Kobold makes: static HTML is zero-cost. It is created in the precompiled JavaScript with absolute minimum of Wasm-JavaScript boundary crossing necessary (one, exactly) and never updated. There is no diffing, not even a branch to check if diffing is necessary.

Injecting Expressions

Let's modify our hello function to render a string slice in the view:

fn hello(name: &str) -> impl View + '_ {
    view! {
        <h1>"Hello "{ name }"!"</h1>
    }
}

The expression in curly braces like { name } must implement View itself, which &str does. Notably a View only needs to live long enough to be used in either build or update, no 'static lifetime necessary. This avoids a whole bunch of temporary clones on each render.

The code this expands to is a bit longer, so let's look at it bit by bit. The important part is:

fn hello() -> impl View {
    /// snip!

    struct Transient<A> {
        a: A,
    }

    Transient { a: name }
}

Given the previous example, you might already see where this is going. The macro defines a new Transient struct with a generic field and it puts the expression in it, in this case just the variable name into that field.

For this to work, the macro also has to implement the View trait for this Transient type. It does it like this:

impl<A> View for Transient<A>
where
    A: View,
{
    type Product = TransientProduct<A::Product>;

    fn build(self) -> Self::Product {
        let a = self.a.build();
        let e0 = Element::new(__e0_22790d91e19a0c42(a.js()));

        TransientProduct { a, e0 }
    }

    fn update(self, p: &mut Self::Product) {
        self.a.update(&mut p.a);
    }
}

Here is where the magic happens. The build method first builds product of the expression a. The View implementation for &str will just make a Text DOM node and hold an allocated String of the name to check for changes later. Adventurous users could also write { name.fast_diff() } to switch to pointer address diffing, making this view completely allocation-free for its entire life cycle.

We then take the &JsValue reference to said text node and call a precompiled __e0_22790d91e19a0c42 JavaScript function with it. Skipping the #[wasm_bindgen] part here is the code:

export function __e0_22790d91e19a0c42(a) {
    let e0=document.createElement("h1");
    e0.append("Hello ",a,"!");
    return e0;
}

This is almost identical to the previously generated function, except we append our Text node as variable a between two static text nodes: "Hello " and "!". Rust only knows about that one Text node containing the name we need to render and the root (<h1> in this case) it needs in order to stick this view into the DOM.

The update method is even simpler: call update on all fields of the Transient with their corresponding products. In our case this just defers the update to the View implementation of &str. No other node in the tree is ever touched: it is zero-cost.

The TransientProduct struct is rather dull so I am not going to explain it here in detail. Suffice to say it just holds all the products for all the expressions, the root Element, and potentially few other hoisted elements that need to have their attributes operated on directly.

Zero-Cost Components

There should be one-- and preferably only one --obvious way to do it.

In Kobold there is only one sanctioned way to create components: by turning plain functions into functional components. This involves:

  1. Changing the name to follow PascalCase scheme, just like Rust structs.
  2. Annotating it with the #[component] attribute macro.

Taking our hello example from above and turning it into a component is as simple as:

#[component]
fn Hello(name: &str) -> impl View + '_ {
    view! {
        <h1>"Hello "{name}"!"</h1>
    }
}

The only difference between that component and a plain Rust function is how you would invoke them in the view! macro:

view! {
    // the `Hello` functional component:
    <Hello name="World" />

    // the `hello` function:
    { hello("World") }
}

The actual interaction between the view! and the #[component] macros is unstable so I wouldn't advise writing component structs by hand. That said, currently <Hello name="World" /> simply desugars into:

Hello::render(Hello { name: "World" })

The function becomes an associated render function on a struct with fields mapping to the original parameters. While this looks more complicated than hello("World"), it has no performance overhead compared to a regular function call. The only purpose components serve is the familiar syntax with named parameters.

Off the Hook!

Astute readers by now are surely wondering how stateful components work. Here is the kicker:

Kobold does not have a concept of a stateful component. The view rendering part is relatively unopinionated about state management. It is entirely feasible to implement React-esque hooks like in Yew or Dioxus as a 3rd-party crate. Reactive signals like in Sycamore or Leptops should also interact nicely with the View trait.

The state management provided by Kobold is in a very real sense optional, you can even opt out of it completely by disabling default features. Assuming you haven't here is an example:

stateful(0, |count| {
    bind! { count:
        let inc = move |_| *count += 1;
        let dec = move |_| *count -= 1;
    }

    view! {
        <p>"Counter is at "{ count }</p>
        <button onclick={inc}>"Increment"</button>
        <button onclick={dec}>"Decrement"</button>
    }
})

This creates a stateful view. In this case the state is just a simple i32 integer. The count argument passed into the closure is of type &Hook<i32> (not related to React hooks, I just like the name). You can read the state from the hook simply by dereferencing it. It itself also implements the View trait so we can just use it directly without having to write { **count }.

The bind! macro creates event-handling closures that can mutate the state via a simple &mut i32 reference. If the macro looks a bit too magical for you, you can always choose to write binds the long way via Hook::bind:

let inc = count.bind(move |count, _| *count += 1);
let dec = count.bind(move |count, _| *count -= 1);

These are currently 100% equivalent, the macro just saves you from having to type "count" 4 extra times.

Without having to repeat much of the documentation what happens here is pretty straight-forward: Clicking the button updates the state of the integer. The main closure is being run on every state change, the count is diffed with the old one and its DOM Text node is updated if necessary.

I personally quite like this model for its simplicity. While I have yet to push it to the point where it becomes really unwieldy, I can imagine that a more robust solution might be necessary for sufficiently complex applications.

Going Forward

For now Kobold is a complete minimum viable product. I reckon the View trait, the view! macro, and the #[component] attribute macro are reasonably stable by now. The next steps would be:

If you have come this far, thank you! Check out the repo or this neat little QR code example using the fast_qr-based kobold_qr. After all, one of my main motivations for doing this was pulling in complex code like QR code generation and have it work instantly in the browser.

Last, but not least...

Why "Kobold"?

Because I am in fact a colossal nerd and Kobolds are familiar, clever, and small.