Solid.js, React, and Vue - reactivity systems compared

Arek Nawo - Jan 26 '23 - - Dev Community

Working on many projects over the years, I’ve used multiple JavaScript UI frameworks. React, Vue, and now Solid.js are the frameworks that I’ve worked with and enjoyed the most.

Recently, especially when working with Solid.js, I started to notice and appreciate small details like how every framework’s reactivity system differs. It’s an important thing to keep in mind while jumping between frameworks as, even though the APIs keep getting more similar, how the framework works underneath influences performance, software architecture as well as how you think about your app in general.

The thing is understanding the framework’s reactivity system to the full extent takes time and requires deep knowledge of its architecture. That’s a lot - especially when working with multiple frameworks. That’s why I wanted to simplify these concepts and provide you a “good enough” starting point so that you both understand the major differences and have a solid entry point to exploring this topic deeper on your own…

Reactivity Systems

If you’ve only ever worked with or are laser-focused on a single framework you might not have thought about its reactivity system or rendering model too much - especially in comparison to other frameworks. How the component state is created and managed, what triggers a re-render, what parts of the UI are being updated, the inner workings of Virtual DOM, and how it all impacts performance - these are questions that you usually don’t consider when building UIs. However, sometimes it’s worth taking a step back and considering how these things impact your entire codebase.

React

In React, when using Functional Components with Hooks, your entire “component function” is what gets executed on every re-render. Take a look at this example:

import React, { useState, useEffect } from "react";

const Example = () => {
  const [count, setCount] = useState(0);
  const [someData, setSomeData] = useState(0);

  console.log("re-render");

  useEffect(() => {
    console.log(`You clicked ${count} times`);
  }, [count]);
  useEffect(() => {
    setInterval(() => {
      setSomeData(Math.random());
    }, 1000);
  }, []);

  return (
    <div>
      You clicked {count} times
      <button
        onClick={() => {
          setCount(count + 1);
          console.log(`Count not updated here yet: ${count}`);
        }}
      >
        Click me
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

As the entire component function is re-run on every re-render, you use React Hooks to both define the state of the component (useState()), as well as to “filter out” pieces of code that should run only when specific state properties are updated (useEffect()). That’s the simplest way to think about the re-renders here - “everything runs by default, you use Hooks to filter out what should not”. That’s by design.

Few other things to note here:

  • Updating someData (in an interval) triggers re-renders even though the value isn’t used in the view. Now, due to the way React works, it’s required to keep the value in the component up-to-date, and, thanks to Virtual DOM, no DOM operation will be performed. However, it’s still a re-render that can be costly in complex components which you need to keep in mind. Changing someData to a ref using useRef() would be the solution here.
  • You can only be sure that count was updated, after a re-render, in the useEffect() callback. The console.log() right after setCount() will still display the old value. It’s not that problematic when you have other ways to access the new value or have adapted your mental model to the way React works. If you’re new to React, or you’re constantly switching between different frameworks, or work on complex components with complex event handlers - this might be an issue. If you want to both update the component as well as have the latest value available right away, you might have to combine useState() with useRef().
  • Things that aren’t as important but also stand out when compared to other frameworks: having to explicitly define effect dependencies (no auto-tracking) and no direct “on mount” lifecycle callback (useEffect(() => {}, []) serves as an alternative).

To battle, these and other “issues” React established “Rules of Hooks” and recommends splitting your code into smaller components. The more granular the component and its state, the lesser the potential overhead for every re-render.

Vue

Here’s an equivalent example for Vue 3 (note: even though it’s less popular in Vue, I’ve used JSX for a closer visual comparison with other frameworks):

import { defineComponent, ref, watchEffect, onMounted } from "vue";

const Example = defineComponent({
  setup() {
    const count = ref(0);
    const someData = ref(0);
    const onClick = () => {
      count.value = count.value + 1;
      console.log(`Count updated here already: ${count.value}`);
    };

    watchEffect(() => {
      console.log(`You clicked ${count.value} times`);
    });
    onMounted(() => {
      setInterval(() => {
        someData.value = Math.random();
      }, 1000);
    });

    return () => {
      console.log("re-render");

      return (
        <div>
          You clicked {count.value} times
          <button onClick={onClick}>Click me</button>
        </div>
      );
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

In Vue, every component has an entry setup() function. It’s here that you use Composition API to set up your component’s logic and finally, return a rendering function. You see a difference between what runs only once and what runs on every re-render right away. If you want a piece of code to run when certain state property changes, you have to use watchers to “filter them into” the update cycle, as opposed to “filtering out” in React.

On top of that, there are a few advantages to Vue’s reactivity system. First off, Vue does provide actual lifecycle hooks like onMounted(). On top of that, the setup() function itself serves as a great entry point for any logic that doesn’t require the component to be already rendered.

Secondly, thanks to reactive objects and refs based on JS proxies, Vue can automatically detect when a certain effect or re-render needs to be triggered. Thus, you don’t need to provide explicit dependencies in watchEffect() (though you can with the watch()), and setting someData ref in an interval won’t trigger a re-render, since it’s not used in the view.

Finally, when updating a ref, you can be sure that when you read the value again, it’ll already be changed. Keep in mind though that the re-render triggered by that change likely hasn’t happened yet and the UI isn’t up-to-date.

With all that and a lot of optimization on the Virtual DOM and other parts of the framework, Vue has much better performance when compared to React and arguably, a better development experience. The setup() function makes for a great starting point for your components, not having to worry as much about too many re-renders, while Composition API provides similar ergonomics as React Hooks.

Solid

To me, Solid often feels like the best of React and Vue combined. However, this line of thinking doesn’t give me a complete picture of the framework. Once you step beyond basic components, you quickly see that Solid is much different than other frameworks. When working with Solid, you’ll have to forget a lot of what you’ve learned about components and reactivity from other frameworks.

Consider the example below:

import { createSignal, createEffect, onMount } from "solid-js";

const Example = () => {
  const [count, setCount] = createSignal(0);
  const [someData, setSomeData] = createSignal(0);

  createEffect(() => {
    console.log(`You clicked ${count()} times`);
  });

  onMount(() => {
    setInterval(() => {
      setSomeData(Math.random());
    }, 1000);
  });

  return (
    <div>
      You clicked {count()} times
      <button
        onClick={() => {
          setCount(count() + 1);
          console.log(`Count updated here already: ${count()}`);
        }}
      >
        Click me
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

In Solid.js components exist only to organize your code. That’s why a component function is run only once. It’s similar to a component function in React, with properties of the setup() function of Vue. However, once it’s run, the component vanishes, while all that’s left are the JSX elements, reactive primitives, and effects. That’s very different from e.g. Vue, where every component has an instance you can access. Still, Solid does provide some lifecycle hooks like onMount() but they’re more focused on reactive scope rather than the actual lifecycle of the component, like in Vue.

Another big part of Solid.js is its fine-grained reactivity system. While the API might seem similar to React Hooks, it’s completely different underneath. Solid’s reactivity is built on signals that are automatically tracked to appropriately trigger effects and UI updates. Thus, similarly to Vue, you don’t need to pass explicit dependencies to createEffect() (even though you can with on()), while updating data that’s not in the view (like someData), won’t trigger a UI update.

Solid is the only framework of the three not to have a Virtual DOM. Thanks to fine-grained reactivity, and compiler optimizations, Solid is able to quickly update just the right part of the UI - synchronously. This results in charts-topping performance and reassures you that your UI is always up-to-date. Thus, after calling the setCount() function you can be sure that both the value and the UI have already been updated.

Conclusion

As you can see, even though the frameworks seem similar on the outside, many underlying differences make or break performance and developer experience.

Personally, over the last few years, Solid has been my favorite. Thanks to its top performance, reactivity system, and API, it’s been a development experience like no other. That’s why it’s powering this blog, Vrite landing page, and soon - Vrite itself. If you’re interested, give Solid a look!

. . . . . . . . . . .