DEV Community

Cover image for Voby: Simplifications Over Solid - No Babel, No Compiler
Fabio Spampinato
Fabio Spampinato

Posted on • Edited on

Voby: Simplifications Over Solid - No Babel, No Compiler

Introduction

Hello 👋, my name is Fabio and in the process of trying to deeply understand the awesome Solid framework I ended up writing my own standalone reactivity library, Oby, and a Solid-like reactive framework on top of it, Voby.

This "Simplifications Over Solid" series will touch on about 30 points where Voby and Solid made different decisions, and my arguments for why Voby's decisions end up simplifying the framework and/or improving developer experience overall.

This series will largely contain personal opinions, if what you like is different you may very well see some of Solid's decisions as the simplifications instead, and you may very well consider some as being over-simplifications. Also Voby is somewhat narrower in scope, it's designed for writing rich and performant client-side apps, things like VS Code rather than a blog, the potential server-side applications are largely out of scope for Voby (though potentially one should be able to wire it with Astro or something, and a basic renderToString function is provided), but they aren't for Solid. Just keep that in mind.

I'll be comparing Voby with Solid because Solid is the framework that effectively taught me how to write Voby, they are fairly similar in how they work as a result, and other frameworks are either too different or I don't know them well enough to compare against them.

You should read this series, maybe, if you are interested in exploring different choices that different frameworks made, if you are interested in reading somewhat in depth opinions about some of Solid's details, or if you are looking for a client-side-only framework for your app.

Let's start with just the first major simplification: not having a custom Babel transform or a custom compiler.

#1 No custom Babel transform

Voby has no custom Babel transform for the JSX, and no custom compiler of any kind, it just works out of the box with the transform that TypeScript ships with, or with no JSX at all if you are into that.

This is what some sample Voby code looks like:

import {$, render} from 'voby';

const Counter = () => {
    const value = $(0);
    const double = () => value() * 2;

    const increment = () => value(prev => prev + 1);
    const decrement = () => value(prev => prev - 1);

    return (
        <>
            <h1>Counter</h1>
            <p>Value: {value}</p>
            <p>Double: {double}</p>
            <p>Triple (inline): {() => value() * 3}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
        </>
    );
};

render(<Counter />, document.body);
Enter fullscreen mode Exit fullscreen mode

This is what the equivalent Solid code looks like:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const Counter = () => {
    const [value, setValue] = createSignal(0);
    const double = () => value() * 2;

    const increment = () => setValue(prev => prev + 1);
    const decrement = () => setValue(prev => prev - 1);

    return (
        <>
            <h1>Counter</h1>
            <p>Value: {value()}</p>
            <p>Double: {double()}</p>
            <p>Triple (inline): {value() * 3}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
        </>
    );
};

render(() => <Counter />, document.body);
Enter fullscreen mode Exit fullscreen mode

Advantages

Here are some advantages that this decision unlocks.

  • Voby works out of the box with TypeScript/Deno/Bun/Esbuild/Whatever™.
    • For Solid you usually need some kind of plugin, and for newer tools like Bun I'm not sure it's usable at all yet.
  • Voby is able to take full advantage of the bundler's performance.
    • If you use Solid with, for example, Esbuild, you'll have to parse your code at least twice, once with Esbuild (fast) and once with Babel (slow-ish).
  • Voby's JSX transform adds zero maintenance burden, since the implementation of the JSX transform exists outside of the framework itself.
    • Solid's transform seems fairly time consuming and difficult to maintain to me, though maybe it isn't, I'm not familiar with that code or with writing Babel transforms in general, you decide.
  • Voby's transform is trivial to understand if you are familiar with React, because it's exactly the same transform.
    • Solid's transform I'm not sure if it's documented in detail anywhere, there are lots of nuances to it, like fundamentally it's 500+ lines of code I haven't read and understood, I'm personally much more confident that I understand fully React's transform than I understand Solid's transform, because React's transform is very simple.
  • Voby's JSX transform is basically bug-free, because it doesn't even have one and it instead uses the battle-tested React-like one existing tools already ship with.
    • The custom Babel transform complicates things significantly in Solid. For example, there's a fundamental problem which is that the transform tries to compile away known props into the minimal code necessary for them, but that clashes with props spread onto the component (e.g. {...someProps}), which are necessarily only known at runtime. Like you may be setting "class" via regular props and "class" multiple times via spread props, how does that work? Which one wins? I'm not entirely sure. It's not rare to see bugs or strange/unexpected behaviors around Solid's transform because of this and other details. Maybe some of these rough edges in Solid's transform will be ironed out in the future? In Voby that's not even a problem, all props are resolved at runtime into a plain object, and the last "class" that you set always wins, exactly like in React.
  • Voby's publishing experience is trivial, you just compile your code with TypeScript and you are done, just like usual.
    • In Solid you could do the same, but that ties you up to the specific version of the JSX transform you are using, if something in there changes or is fixed then either you won't benefit from it or things may break. Instead I think the recommended approach for Solid is just to ship the non-transformed JSX as is, which is mostly fine, but just to mention one instance where that complicates things: you just can't import JSX in an ESM-only setting, because JSX is not JavaScript. And what if your JSX happens to work only because it leverages some minor detail of the transform that is changed in the future? Even publishing non-transformed JSX won't save you from bugs in that (hypothetical) case. Voby doesn't have this problem because at this point React's transform can't be changed anymore, it's effectively set in stone, and it works.
  • Voby requires fewer development dependencies. Zero actually, you can just import it in the browser directly if that's your thing. With all the chatter about installing dependencies faster with different tools, or about dependencies posing a security risk, the hard incontrovertible fact is that no tool can make what you are doing faster or safer if you are simply not doing anything, not installing the dependencies in the first place is simply unbeatable.
    • Solid requires Babel and all of its dependencies. I'm not sure how much slowdown that adds, however much that is it's probably largely irrelevant in my opinion, but if you care about that I'm certain that installing Babel will always be slower than simply not installing it.
  • Voby's API interface is arguably less leaky because there are 0 undocumented exported functions.
    • Solid has a few of them that exist just because the custom Babel transform needs them to work. Voby doesn't need them because there's no custom Babel transform.
    • For example there's a "memo" function, you can import it but it's not documented, and there are others like that. I don't really know how many, or what these functions do exactly, because I haven't looked into it and they are undocumented as far as I can tell.
  • Lastly I should mention that you could in theory use Solid without the custom Babel transform too, I think you have to import it from solid/h and write your code a little differently, more like Voby's, but that's possible. If you do that most of the advantages mentioned above disappear.
    • Though I'm not sure if this is documented anywhere on the site, I'm not sure if anybody is using it, and if you use it you'll lose compatibility with the existing Solid ecosystem that instead uses the transform, because presumably the ecosystem ships Solid-flavored JSX but you are not using that. Like, this problem just doesn't exist in Voby, whichever way you author your components at the end of the day doesn't change anything and it's compatible with all the others. Though to keep things fair Voby doesn't really have an ecosystem to begin with, but that's a problem that exists outside of the framework itself.
    • Also while in theory Solid is usable without a custom transform, I think it matters if approximately 0% of the userbase is using it like that, it matters if it's considered a second-class feature, if it's not used enough to make potential bugs surface, and if it's practically incompatible with other ways of using the framework. In Voby not using a custom Babel transform or a custom compiler is just the obvious and only option.

Performance

But what about performance? Surely Solid is much faster than React because of the custom transform, right? Well, there isn't really much nuance to this question, the answer is a resounding "no", the custom transform has almost nothing to do with that.

Take a look at js-framework-benchmark, the best most comprehensive benchmark for comparing JavaScript UI frameworks that I know of.

Here's the filtered performance table:

Performance Table

Notice how Solid adds ~6% performance overhead over vanilla (that "1.06" number at the bottom of its column in the table) while Voby adds ~8% ("1.08" in the table), and React adds ~69% ("1.69" in the table), unless you are using concurrent features, in which case it adds ~86%. And there's some ~2% of noise between runs of the benchmark.

And remember, Voby and React use the same exact JSX transform in this benchmark.

And here's the filtered memory usage table:

Memory Usage Table

Notice here how Voby adds 5.1MB of overhead over Vanilla (21.3MB vs 16.2MB for Vanilla in the "run memory 10k" test), while Solid adds 7MB of overhead (23.2MB total). React adds 18.1MB of overhead (34.3MB total), having to pay for the VDOM and possibly also for features you may not need very clearly increases memory usage, it basically uses more than triple the memory that Voby uses, considering that almost all the memory that Vanilla uses is just to hold the DOM nodes in memory, which we can't reduce.

  • Looking at the performance table above Solid and Voby are (surprisingly?) close, all the way to the left in terms of relative overhead over Vanilla, rather than all the way to the right like React. Voby is faster than Svelte, faster than Angular, faster than Vue, faster than React. The frameworks that it isn't faster than, like Solid, are about as fast really, that should tell you all there is to know about a framework's performance and compilers, there simply isn't much runtime overhead that a compiler could possibly optimize away in an already optimized framework, you will never beat Vanilla in performance by throwing a compiler at the problem and we are already so close to Vanilla.
  • The truth about compilers/transforms and performance, in the context of JS front-end frameworks, is that compilers and transforms are like 95% about convenience features, actually, and 5% about performance. Meaning that a JS framework that heavily uses compilers can at best have a slim performance edge over an optimized framework that doesn't use a custom compiler or a custom Babel transform at all, when looking at runtime performance. If you don't agree with this, well, evidence doesn't agree with you.
  • It's important to note though that to the best of my knowledge there's one potentially significant optimization that a compiler can make easy for you, which is deeply cloning DOM nodes in one go rather than creating each little node in the hierarchy one by one and appending them one by one. That's potentially significant, and Solid's transform does that for you automatically, that's awesome and in my opinion that's the only really nice thing about Solid's custom Babel transform. Voby can do this optimization too but via an opt-in runtime template helper function, though at the moment that's a bit less general. Solid's solution here is strictly better and simpler, from the user's point of view.
    • It's worth mentioning though that purely from a performance perspective even better than making new nodes faster is reusing the nodes you've already got, Voby has a powerful component for this, ForValue, which could be implementable on top of Solid too but today it isn't, which you can use to re-use nodes. Though you need to be careful with that because in some cases you want to make sure there's no state in the re-used nodes that's not reset, like animations perhaps, but that can be handled, with some care. I personally use ForValue to render virtualized lists, which are possibly the most important component in the app I work on, and that's just impossible to beat with any amount of compiler optimizations, for example it allows my lists to create and destroy zero nodes while scrolling, even if you could clone nodes with magic in 0 nanoseconds that couldn't possibly be any faster than just re-using the same nodes you've already got.
    • Also if your components mainly just call other components, like it's often the case in practice outside of benchmarks, then this optimization that Solid's transform does isn't going to help you much, because there could just be nothing that could be deeply cloned in one go because all the various nodes that a component creates would be crated for it by other components.
    • So, this compiler optimization is great, having it has no direct downsides, but in practice if you really care about performance you may be able to do better than that at runtime, though it will be more time consuming and challenging to implement correctly. And the usefulness of this optimization outside of benchmarks could potentially approach zero too.
  • Hammering on this point one last time: the next time you hear somebody say "[BLANK] is fast because of the compiler", and if they are talking about runtime performance, check if Voby is faster than that in js-framework-benchmark, if it is you can just replace that statement with "bullllsh*t", and if it isn't how much faster is it? 3% maybe?

Convenience

But what about convenience then? You said that's where 95% of the value is, right? Maybe, "convenience" is a personal thing, what looks like convenience for someone may look like a footgun to somebody else.

Before we go through what Solid's transform actually does I'll just mention that you can copy/paste all Solid snippets below into Solid's amazing Playground, which also has an "Output" tab that you can use to see for yourself how your code is transformed. And you can paste Voby's snippets into this codesandbox, just remember to keep the import * as React from 'voby'; in the file (I didn't bother setting this up properly in CodeSandbox, sorry).

Let's now take a look at everything that I can think of that Solid's transform does for you.

Deeply cloning nodes

Solid code:

import {render} from 'solid-js/web';

const App = () => {
    return <div><span><b>Hello</b></span></div>;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Nodes are deeply cloned for you when possible, rather than created and attached one by one. We've touched on this already, it's great, but the result will vary in the real world, and it can be beaten at runtime by reusing nodes instead. Nothing else to say really, it's a potentially important nice-to-have.

Voby's equivalent code is basically the same, but if you don't use the template helper and if you need to mount many of these <App /> component it will be slower, maybe some ~15% slower ? The bigger the node hierarchy that can be cloned in one go the faster it will be in Solid relative to Voby.

Something like the following instead, even though it looks very similar, will be just as fast in Voby even without using the template function, because this can't be optimized by Solid's transform:

import {render} from 'solid-js/web';

const Div = props => {
    return <div>{props.children}</div>;
};

const Span = props => {
    return <span>{props.children}</span>;
};

const B = props => {
    return <b>{props.children}</b>;
};

const App = () => {
    return <Div><Span><B>Hello</B></Span></Div>;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Voby's equivalent code is basically the same, but if you like you can also destructure your props. If you destructure your props in Solid, in general, things will break.

Automatically wrapping props with function calls in them

Solid code:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const Count = (props: {value: number}) => {
    return <p>{props.value}</p>;
};

const App = () => {
    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    return <Count value={count()} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

If the value of one of your props, including "children", contains a function call, like count() above, that's wrapped automatically for you in a function, because otherwise with a React-like transform you would just be passing along a primitive, and primitives can never change, so your child component wouldn't be able to react to any changes.

Essentially if you come at this with a React mindset it looks like we are just passing a number along, so you'd expect p to receive the following props object:

{ value: 0 }
Enter fullscreen mode Exit fullscreen mode

But the actual props object will look more like this:

{ get value() { return count(); } }
Enter fullscreen mode Exit fullscreen mode

This way merely accessing the prop calls the signal. This way props are reactive, but: you can't know which ones, and now you can't destructure your props anymore because if you do that you'd be reading your signals then and there, and not inside an effect or a memo, which is what can be re-executed when signals change.

Also, in my opinion interestingly, you can't have Solid components that at the type-level express that some of their props won't be reacted to, like a component that only accepts a "number" for some prop. It's just not possible to express this, because to TypeScript in the code above it looks like a number is being passed, it's not aware of Solid's transform, the code that TypeScript thinks will be executed is not the actual code that will be executed. For any component that accepts primitives you can always just pass signals to it, and you won't see any compile-time errors and perhaps not even a runtime error, when that's a problem you'll just see your component not updating as you perhaps expected it to.

Voby sees this very differently, forcing the receiving component to always specify if it can handle a primitive or also a signal with types makes it more difficult to have bugs. This way if you pass a signal to a component that says that can't handle signals you'll get an error at compile-time. Probably even the editor will tell you there's an error immediately.

Voby definitely prefers being explicit with types rather than implicit. Solid instead values unwrapping signals for you, which causes all sorts of negative consequences, like the inability to destructure props, which we've mentioned already.

In Voby things that may be signals, or functions, are unwrapped like this $$(count), where $$ is a function exported by Voby, in Solid they are unwrapped like this props.count, at the end of the day $$() is 2 fewer characters than props., it doesn't require any extra branching in your components either, and it's explicit, and also you often don't even need to unwrap props if you are just passing them along to something else, so Voby went with that, and in my opinion that's much simpler and more powerful.

This is the equivalent Voby code:

import {$, render} from 'voby';

const Count = ({value}: {value: number | (() => number)}) => {
    return <p>{value}</p>;
};

const App = () => {
    const count = $(0);
    setInterval(() => count(count() + 1), 1000);
    return <Count value={count} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

The main thing to note here is that we are writing value={count} rather than value={count()}, because our Count component says that it accepts a function value too (which could be reactive if it reads signals inside itself, or if it is a signal itself, like in this case), and we want it to react to it, so we just pass the signal to it and that's it.

Notice how there's no unwrapping with the $$ function that Voby exports anywhere, because we are just passing the prop along to the native <p> component, it will unwrap that for you internally.

Notice how props are being destructured also. It doesn't matter in Voby if you destructure your props or not. If anything destructuring is a bit more memory efficient because the wrapper object can be garbage-collected. If you try to do the same in Solid you'll see that your Count component will stop updating itself. Because the signal was read outside of a reactive context, because merely accessing the prop, via destructuring in this case, calls the signal.

Notice also how the type for our Count function is explicit, and arguably more complicated already with just one prop. But in practice that can be simplified a lot, you could alias that logic into a FunctionMaybe type like this:

type FunctionMaybe<T> = T | (() => T);

// Then our props become...

type Props = { value: FunctionMaybe<number> };
Enter fullscreen mode Exit fullscreen mode

But even better you can just import this type from Voby and write a global alias for it, if that's what you like, and this is what I'm doing myself actually:

import type {FunctionMaybe} from 'voby';

// Convenient alias

type $<T> = FunctionMaybe<T>;

// Then our props become...

type Props = { value: $<number> };
Enter fullscreen mode Exit fullscreen mode

That's much more reasonable, right? If your component supports reacting to every prop you could potentially also construct a type that applies FunctionMaybe to every prop, so that you could write something like the following, which doesn't require any more wrappers if more props will be added:

type Props = $<{ value: number }>;
Enter fullscreen mode Exit fullscreen mode

There's also read/write segregation to consider here, but we'll touch on that another day, I'll just briefly say that that's invaluable if you are writing JavaScript, but if you are writing TypeScript that's much less useful. In our example above even though we are passing the setter along to our Count component in Voby, because by default getter and setter are one function in Voby, our Count component has no clean way to use it really, first of all you'd have to assert that the value has in fact a setter in it, and then there's no way to actually check if it's also a setter or not, we could have passed Count only a getter for real, so what could you do? Wrap it in a try..catch, call it, and hope for the best? In practice you'll never write code like that anyway, like, you'd have to explicitly shoot yourself on both feet too much with TypeScript yelling at you the whole way through, it's just not going to happen. And if that doesn't convince you you can also have hard Solid-like read/write segregation by writing your own 3-lines createSignal for Voby.

Basically Solid might argue that this props transformation is done for your convenience. From my point of view almost always it's more convenient not to have that transformation, actually, and it's safer too because it allows you to express at the type level if some props won't be reacted to, which you can't do in Solid.

Also, the way Voby does it is more consistent, the same way you can pass a primitive or a signal to a component you can also pass them to a hook, or "primitives" as Solid calls them, but Solid's transform has no way of detecting your hooks, they are just functions, and hooks aren't generally written to accept a single props object anyway, so what would you do then? You'd have to necessarily unwrap the arguments you receive yourself. This special case simply doesn't exist in Voby, there's only one way of doing things, and on average it's arguably more convenient too.

By the way Solid has no function for checking if something is actually a signal, by design, so if you want to unwrap a value that could be either a callback or a signal to a callback what are you going to do? There's no direct equivalent to Voby's isObservable to check if something is a signal (which in Voby we call "observables", because they are what can be observed by "observers", which are effects and memos) nor a convenient $$ function for unwrapping.

Automatically wrapping props with property accesses in them.

Solid code:

import {render} from 'solid-js/web';
import {createMutable} from 'solid-js/store';

const Count = (props: {value: number}) => {
    return <p>{props.value}</p>;
};

const App = () => {
    const state = createMutable({ value: 0 });
    setInterval(() => state.value++, 1000);
    return <Count value={state.value} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

The same automatic wrapping that we saw before for props containing a function call in them is also performed for props that contain property accesses in them, store.value in this example. For the same exact reasons, and with the same exact problems.

In Voby you need to be explicit, if what you are passing on is a number that simply can't possibly be reacted to, you'd have to pass on a function if you want the component to react to it, so in this case:

import {render, store} from 'voby';

const Count = ({value}: {value: number | (() => number)}) => {
    return <p>{value}</p>;
};

const App = () => {
    const state = store({ value: 0 });
    setInterval(() => state.value++, 1000);
    return <Count value={() => state.value} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Slightly more code, but explicit, TypeScript understands much better what our code does.

Magic refs

Solid code:

import {render} from 'solid-js/web';
import {onMount} from 'solid-js';

const App = () => {
    let ref: HTMLDivElement;
    onMount(() => {
        ref.textContent = Date.now();
    });
    return <div ref={ref!} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

The transform enables this alternative way of defining refs, which looks like you are just creating a variable and passing it along as a ref, which if you come from a React mindset doesn't make any sense whatsoever: you are making a variable, the variable is never initialized, that variable is passed as a ref, so effectively you've written ref={undefined}, and somehow this works?

The trick is that the transform transforms ref={ref} into ref={element => ref = element} basically.

It looks just like a convenience feature, even though it's tricky with types because TypeScript gets confused here, understandably.

In practice I would argue this feature should never be used, and in general in fact it can't be used because in some cases it causes bugs, bugs that are hard to spot at first. Let's look at this other Solid snippet:

import {render} from 'solid-js/web';
import {createSignal, createEffect, Show} from 'solid-js';

const App = () => {
    let ref: HTMLDivElement;

    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    const [mounted, setMounted] = createSignal(true);
    setInterval(() => setMounted(mounted => !mounted), 1000);

    createEffect(() => {
        ref.textContent = count();
    });

    return (
        <Show when={mounted()} fallback={<div>Unmounted!</div>}>
            <div ref={ref!} />
        </Show>
    );
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

This may look super complicated if you aren't familiar with Solid, but basically we changed the snippet like this: there's a "count" signal that's incremented every second, there's a "mounted" signal that's toggled every second, our <div> is wrapped in a Show, and that Show basically one second mounts the div, the next second unmounts it, then it mounts it back again etc. That's it, our ref is defined the same way, we are just writing the value of "count" in it now.

Can you spot what the problem is?

The problem, fundamentally, is that these magic refs are just variables, they can't notify us of any updates because only signals can do that. So what's happening here is that our div actually changed, a new one is created every 2 seconds, but our ref variable didn't tell us that that happened, because how could it? We are just updating the wrong divs here!

Basically magic refs are totally broken if the node they are attached to isn't rendered always and immediately. If it's rendered with a delay, or if it's rendered conditionally, magic refs are simply unusable.

Notice how if we are passing one of these magic refs to a custom component we can't even see immediately what that will be attached to, you'll need to look at the code for that component, and understand this problem, to be able to tell if using a magic ref is a safe thing to do or not in that case.

So in my opinion this is an obvious feature to avoid, unless you like your code to be a minefield of subtle bugs.

The correct way to make a ref is simply to use a signal, like in this Solid snippet:

import {render} from 'solid-js/web';
import {createSignal, createEffect, Show} from 'solid-js';

const App = () => {
    const [ref, setRef] = createSignal<HTMLDivElement>();

    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    const [mounted, setMounted] = createSignal(true);
    setInterval(() => setMounted(mounted => !mounted), 1000);

    createEffect(() => {
        const node = ref();
        if(!node) return;
        node.textContent = count();
    });

    return (
        <Show when={mounted()} fallback={<div>Unmounted!</div>}>
            <div ref={setRef} />
        </Show>
    );
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

This just works. Also notice how we are forced by TypeScript to check if we've actually got a node here. With magic refs the idiomatic way to please TypeScript is to make a type assertion, and that can just blow up in your face at runtime if the node you expect to be already attached hasn't been attached yet.

In Voby the obvious and simplest way to make a ref is the correct one, to make a signal (or "observables" as we call them in Voby, but it's the same thing):

import {$, render, If, useEffect} from 'voby';

const App = () => {
    const ref = $<HTMLDivElement>();

    const count = $(0);
    setInterval(() => count(count() + 1), 1000);
    const mounted = $(true);
    setInterval(() => mounted(mounted => !mounted), 1000);

    useEffect(() => {
        const node = ref();
        if(!node) return;
        node.textContent = count();
    });

    return (
        <If when={mounted} fallback={<div>Unmounted!</div>}>
            <div ref={ref} />
        </If>
    );
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Resolving known props at compile-time

Solid code:

import {render} from 'solid-js/web';

const App = () => {
    const [color, setColor] = createSignal('red');
    setInterval(() => setColor(color() === 'red' ? 'blue' : 'red'), 1000);
    return <div class={color()} {...{ class: "bar"}} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

In this snippet we have a "color" signal, which flips back and forth between "red" and "blue", and we are setting that as a class on a <div>. But we also have a spread on the element, in reality the props to spread on the element will most probably come from a parent component and what will be spread will only be known at runtime, for simplicity I've inlined a simple object in the snippet above.

Let's look at what that code is compiled to in the playground:

import { render, spread, effect, className, template } from 'solid-js/web';

const _tmpl$ = /*#__PURE__*/template(`<div></div>`, 2);

const App = () => {
    const [color, setColor] = createSignal('red');
    setInterval(() => setColor(color() === 'red' ? 'blue' : 'red'), 1000);
    return (() => {
        const _el$ = _tmpl$.cloneNode(true);

        spread(_el$, {
            class: "bar"
        }, false, false);

        effect(() => className(_el$, color()));

        return _el$;
    })();
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

A lot of stuff happened here it seems.

The main thing I want to highlight is that Solid knows already, thanks to us writing class={color()}, that it has to set a class on the node, so it directly outputs the necessary code for that: effect(() => className(_el$, color()));.

That's nice, but how nice is it really? Voby is checking what to do for each prop completely at runtime, and there isn't really much performance to be gained from doing this at compile time anyway, if there was Solid would have a big edge over Voby in the benchmark, but as we saw already a big edge over Voby in the benchmark is impossible because it's already so close to Vanilla.

So we got this little optimization, but now also some code got emitted for handling the spread. So what class will our "div" have really? Looking at the code I'm not exactly sure, it looks as if first "bar" will be set, then that will be replaced immediately with "red" and then it will flip back and forth between "blue" and "red". Unless I'm reading the code wrong.

And that's with a single spread with static values. Imagine if the spread set a class that depends on a signal too, imagine if you had multiple of these spreads? I'm not sure what would happen in every case.

In Voby, like in React, a single plain-object props object is produced at runtime first of all, and the last property set on it always wins.

So in practice our props object in this case will be { class: color, ...{ class: "bar" } }, which is equivalent to { class: "bar" }, which is trivial to handle and obvious how it's handled.

So is this optimization really worth it? In my opinion even if you never do spreads it just adds too much complications for little if anything to gain from it.

Notice also how as of today, if you are using spreads, potentially this may not even be an optimization really, but a de-optimization, because now you have multiple effects that each think they manage your "class" attribute fully. If this conflict didn't exist in the first place you'd just have 1 effect, not N.

Universal rendering

Solid's transform enables rendering to things other than the DOM, basically it replaces all DOM API calls with calls into APIs that you provided, so if your APIs support rendering to a <canvas>, or to the terminal, or something else, then you can just write normal Solid code, but render it to a different target than the DOM.

That's cool. But unless you need to use multiple of these rendering targets at the same time the framework could just export an API object for you to override and that's it, no need for the transform.

Personally I don't really need this feature, not with multiple different targets at the same time, not with a single non-DOM target either, I just simply use Voby to render to the DOM only.

From a "simplifications" point of view this is a niche feature that adds complications to the framework, especially if you need to support multiple rendering targets at the same time. I don't need this, almost everyone doesn't need this either, so I just haven't implemented it.

Smaller bundle sizes

Solid's custom transform enables sending slightly less code to the browser, if you use SSR, because some code can be executed in your computer rather than on your users' computers.

This is potentially nice, but we are talking about 3kb or something like that, maybe even less.

If you are writing something tiny, or something with specific constrains like an e-commerce, that could be appealing for you, but for rich client-side applications, which is what I'm interested in, that's approximately insignificant. For example in VS Code the editor component alone, Monaco, is responsible for 1MB+ of JavaScript.

Essentially for rich client-side apps SSR is effectively useless. The instance of VS Code I have running at the moment on my computer got started about 10 days ago, as a user I don't care in the slightest if startup time took 2ms longer because 3kb of code could have been trimmed away with SSR. SSR just doesn't make a lot of sense in this context.

Making logic operators reactive

Solid code:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const App = () => {
    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);

    return (
        <>
            <p>Even (Ternary): {count() % 2 ? 'no' : 'yes'}</p>
            <p>Even (Binary): {count() % 2 && 'yes'}</p>
        </>
    )
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

This is pretty cool, we just wrote what looks like normal JavaScript, but somehow we are seeing our page being updated as if those logic operators were reactive? Yep, the transform basically transforms that into the equivalent JavaScript code that you'd have to write for it to be reactive.

A sort of gotcha here, in my opinion, is that you should look at that code as if it's not exactly JavaScript, like, a ternary operator can't be made reactive in JavaScript, but that's reactive in Solid (inside the JSX), so that can't really be a JavaScript ternary operator. Said it differently this same behavior can't be implemented with React's transform in Voby.

A more important detail is that for anything that does branching, like these logic operators, there are two ways that things could be updated: when the condition changes, while still staying truthy or falsy, either your component is refreshed or it's cached, they are sometimes called respectively "keyed" and "non-keyed" updates.

The important thing to understand here is that sometimes you want keyed behavior because it could be more correct, other times you want non-keyed behavior because it could be just as correct but much faster. And the problem with this part of the transform is that I don't really know which behavior those operators use in Solid because I don't think it's documented, but more importantly there's just no way to switch between the two and pick the correct one under different situations. So arguably it'd be better to instead use something like Solid's Show component, or Voby's equivalent If component, for branching logic, because with a custom component you can pick the behavior that you want.

This would be the equivalent code in Voby, with explicit wrappers:

import {$, render} from 'voby';

const App = () => {
    const count = $(0);
    setInterval(() => count(count() + 1), 1000);

    return (
        <>
            <p>Even (Ternary): {() => count() % 2 ? 'no' : 'yes'}</p>
            <p>Even (Binary): {() => count() % 2 && 'yes'}</p>
        </>
    )
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Giving you raw DOM nodes

Solid code:

const node = <div class="foo" />;
console.log(node instanceof HTMLDivElement); // true
Enter fullscreen mode Exit fullscreen mode

Solid's transform enables giving you raw DOM nodes directly just like that. No VDOM, no intermediate function, just the DOM nodes directly, that's pretty cool.

Voby code:

const node = <div class="foo" />;
console.log(node instanceof HTMLDivElement); // false
console.log(typeof node === 'function'); // true
Enter fullscreen mode Exit fullscreen mode

In Voby instead, while it doesn't have a VDOM either, you always get a function that once called will give you the result, in this case once called it will give you the <div>.

This is a requirement in Voby because it uses React's transform, it's not possible for it to give you raw DOM nodes directly with that transform (unless you want bugs). And this touches on possibly the most important feature of Solid's transform really: calling parents before children, in order to do that Voby has to return you functions so that it can call components in the right order. Solid doesn't need that because it uses a different transform.

Overall this is a pretty cool feature in Solid, especially on an intuitive level, you write a <div /> and you just get an actual <div /> back, that just feels right.

In my current ~20k lines component library using Voby I think I could have benefited from this feature exactly once. Cool, but in practice I don't think it's particularly important, I wouldn't want to pay the price of a custom transform for it. Maybe for a different use case this feature could simplify much more code, you decide.

/* @once */ comment

Solid code:

import {render} from 'solid-js/web';
import {createSignal} from 'solid-js';

const Count = (props: {value: number}) => {
    return <p>{props.value}</p>;
};

const App = () => {
    const [count, setCount] = createSignal(0);
    setInterval(() => setCount(count() + 1), 1000);
    return <Count value={/* @once */ count()} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

This is the same snippet that we saw a few points above, but instead of count() we are writing /* @once */ count().

Solid has that special comment for this reason: say you want to actually pass a primitive to the component, how are you supposed to do that if writing count(), unwrapping the signal, is actually turned into a getter to pass the signal as a prop? That's what this comment solves, it just tells the transform not to turn that into a getter.

To me that comment looks very odd. First of all I wouldn't want any library I use to introduce any special comments in the language I use. Like if you think about it that's not even possible, you can't make a comment special in JavaScript. But that comment is special in Solid, so that's not JavaScript. In truth obviously JSX never was JavaScript, but React's transform is what TypeScript ships with, so in some sense that's plain TypeScript at this point, and there's very little magic in that transform anyway, so by that reasoning Solid code is just not plain TypeScript because it has different semantics.

But also fundamentally the equivalent code in Voby just feels more natural to me:

import {$, render} from 'voby';

const Count = ({value}: {value: number | (() => number)}) => {
    return <p>{value}</p>;
};

const App = () => {
    const count = $(0);
    setInterval(() => count(count() + 1), 1000);
    return <Count value={count()} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Almost the same code that we saw a few points above, but instead of writing count={count} we are now writing count{count()}. That looks normal right? Previously we wanted to pass on a signal, so we passed on a signal, now we want to pass on a number, so we are passing on a number. In Solid this is basically backwards, unwrapping the signal passes on the signal, not the unwrapped value.

At the end of the day I think one would get used to writing SolidScript, as I like to call it, very quickly, but I still find super weird that writing count={count()} in Voby is not reactive, but it's reactive in Solid. I understand why that happens in Solid, but I don't like it, it just feels wrong to me.

Directives

Solid code:

import {render} from 'solid-js/web';
import {createSignal, createEffect} from 'solid-js';

const model = (el, value) => {
    const [field, setField] = value();
    createEffect(() => el.value = field());
    el.addEventListener("input", (e) => setField(e.target.value));
};

const App = () => {
    const [name, setName] = createSignal('');
    return <input type="text" use:model={[name, setName]} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

Directives are amazing, I love them, they are basically an extension mechanism for the JSX. Essentially they allow you to define custom "use:*" attributes on native elements.

Said it differently directives are like hooks that you don't need to pass a ref to, because they get passed one from the framework.

The way they are used in both Solid and Voby is very similar, what changes is how they are defined.

In Solid, if you paste that code into an editor, you'll see immediately that we have a problem already. TypeScript thinks that that "model" function is never used, because it can't possibly know that Solid's transform is active, but if you write "use:model" in Solid then its transform expects to find a function called "model". How do we solve this?

There's just no clean solution, you'll have to write some dummy code to trick TypeScript into believing that it's actually used.

Also we should extend the JSX.Directives interface to tell TypeScript about this new attribute we created, that's fine, we should do that. But there's one slight conceptual problem here: our type definitions are effectively global, every Solid file will use the same JSX types, but the definition of our directive is local, different files could potentially have different and incompatible implementations. Neither the transform nor TypeScript will always be able to understand this problem at compile time, and in fairness you'll most probably never stumble on this problem yourself, because why would you want to use two incompatible directives under the same name? But it's something worth highlighting.

In Voby directives instead are created with a createDirective function and attached like the context, and globally, if you follow what's recommended. They are effectively just attached on the context under a special symbol.

Equivalent Voby code:

import {$, createDirective, render, useEffect} from 'voby';
import type {Observable} from 'voby';

const ModelDirective = createDirective( 'model', (target: HTMLInputElement, value: Observable<string>) => {
    useEffect(() => {
        target.value = value();
    });
    target.addEventListener("input", (e) => value(e.target.value));
});

ModelDirective.register();

const App = () => {
    const name = $('');
    return <input type="text" use:model={name} />;
};

render(App, document.body);
Enter fullscreen mode Exit fullscreen mode

That's just it. A directive is an actual object, and you register it using ModalDirective.Provider, in this case, just like with context objects, or with the .register function, which is recommended instead for directives.

Now TypeScript doesn't think that our directive is never used because by calling .register on it it understands that it may have side effects.

Also our directive is now registered with the framework, if we are using this 100 times we don't have to import ModelDirective 100 times, we just need to register it once, it's pretty much simply part of the JSX now.

Registering directives using the context rather than requiring a transform for them is so much nicer to work with in my opinion, and potentially this is a simple change that could be implemented in Solid too, I've no idea why the transform is used instead, it just seems more complicated and way less usable to me.

You may think that directives are registered with the transform in Solid for performance reasons, since in Solid the transformed code has a reference to the directive directly, while in Voby this involves a context lookup. But that's not true, if you only ever register directives globally in Voby, as you should, there's no context lookup, because the framework knows immediately where to look for directives since it knows that they have only ever been registered globally, there's no measurable performance overhead to this approach.


There are possibly other noteworthy things that Solid's transform does for you. I can't really think of anything else right now.

Closing thoughts

I love Solid

At this point I should probably explicitly say that I love Solid, working with reactivity primitives just feels right to me and Solid opened my eyes to it.

There are just some details about it that I don't like, and some features that are superfluous for my use case.

Without knowing anything about you I would guess that Solid would most probably be more suitable for you and you should definitely check it out. It's unlikely that Voby would be better for your use case, if not for the lack of ecosystem or the lack of nice docs probably because it doesn't really care about SSR. But if you are working on a client-side app, or if you like making bindings for Astro 👀, you may want to look into it too.

I love simplicity

It's very hard to make a complex codebase simpler in the future, if you can it's much better to bet on simplicity right from the start and holding onto it for dear life as long as you possibly can.

After having gone through the trouble of writing my own framework that's what I'm trying to do myself, trying to keep things simple and manageable for as long as possible. Without sacrificing performance, because I care about that too.

One of Solid's features that I decided wasn't worth it for me was the custom Babel transform. I don't need it, nor want it really, do you?

We'll touch on the remaining ~29 points some other time.

Thanks for reading, have a good day 👋

Top comments (4)

Collapse
 
ryansolid profile image
Ryan Carniato • Edited

Great article. Re-using nodes is definitely a thing, but it isn't always a generalizable optimization. At times in the past it has been stressed how dangerous it is to do so. It isn't always dangerous but it is definitely opt in. Cloning nodes might not always be beneficial but it is something we can just do.

I think the biggest thing that makes Voby's DX not suffer and something that I didn't consider early on is the unwrap function. I findisSignal is insufferable from an authoring standpoint if you need to check everywhere. This was the life building re-usable components/directives in Knockout. But simply having a function (and all JSX bindings) be like we don't care we handle both simply by accessing it with a special function alleviates that a lot.

Also, your points on spreads are dead on. It was something I tried optimizing early on and I was mistaken. This has been on the list for a while and each release we've been making incremental improvements to spread and will finally be addressed in Solid 1.6. Which reminds me, in Voby are prop spreads dynamic on components? Like if the properties on a components change dynamically (ie new properties added, others removed). Can the props object itself be a signal in a sense?

Collapse
 
fabiospampinato profile image
Fabio Spampinato

Thank you 😊

I agree 100% regarding re-using nodes.

Regarding unwrapping I agree that $$ is pretty handy, potentially that has an hidden problem though, which is: if I write $$(count) what am I supposed to name the variable that I assign that to? Which sounds silly but I found this to be a real annoyance. When that's a problem I use another function, useResolved, which solves the issue though the code becomes a little weird, it feels like a different take on dependency arrays, maybe it's something similar to what you did in the Knockout days. FWIW it looks like I've called $$ 307 times and useResolved 37 times in my current repo.

Can the props object itself be a signal in a sense?

I've never thought about this 🤔 the object can't be a signal, I guess one would need to sort of unpack it manually. I don't know if object spreads can be intercepted somehow in JS, if they can potentially the library could do the unpacking for you automatically, I should think about that.

Collapse
 
webreflection profile image
Andrea Giammarchi

sad to not see uhtml in the comparison, otherwise great article and results.

Collapse
 
drsensor profile image
૮༼⚆︿⚆༽つ

I recently use HyperAxe on legacy app and this is something I usually do:

let slider: HTMLInputElement
form(
  slider = input({ type: "range" })
)
Enter fullscreen mode Exit fullscreen mode

So if JSX return an instance of Element, I think ref property is really unnecessary because I can acquire the ref by assignment:

let slider: HTMLInputElement;
<form>
  {slider = <input type={"range"} />}
</form>
Enter fullscreen mode Exit fullscreen mode