How React's approach to reactivity can be challenged?

As a React developer, I never really questioned React's approach to reactivity. But I recently discovered two other frameworks: SolidJS and Svelte. Both frameworks have in common to not rely on a virtual DOM and to offer, what I call, “opt-in reactivity”.

To understand what I mean by “opt-in reactivity”, I must first explain how I think React follows an “opt-out reactivity” approach.

React's “opt-out reactivity”

To opt out, in my mind, means to explicitly/actively unsubscribe from something (e.g. to opt out of a mailing list). This suggests that you are subscribed to this thing by default.

Opt out (from) reactivity, thus means, explicitly making something non-reactive or less reactive.

So, how does it apply to React?

In a React functional component, any statement in the function is by default reactive.

function MyComponent(props) {
    // executed each time a prop changes
}

That means it will be executed if any props or states change.

To write a not-always-reactive statement, we can rely on the useEffect function.

function MyComponent(props) {
    useEffect(() => {
        // executed only if the "name" prop changes
    }, [props.name])
}

The same logic applies for useMemo and useCallback which allow recomputing a variable value only if one of the dependencies changes. These tools help us make a component re-render less.

This approach, while powerful in most situations (especially compared to the lifecycle of Class components), has been challenged by other frameworks like SolidJS or Svelte.

Opt-in reactivity

As opposed to opt-out, “opt-in reactivity” means non-reactive unless specified.

SolidJS and Svelte have two radically different implementations of the same approach.

Svelte

To understand Svelte approach, I need to make a quick overview of Svelte's custom component format.

Introduction to Svelte components

Components are written into .svelte files, a superset of HTML. These files contain three (all optional) sections:

<script>
    // logic goes here
</script>

<!-- markup (zero or more items) goes here -->

<style>
    /* styles go here */
</style>

The markup section relies on a template syntax using curly braces {expression} for JavaScript expressions and blocks like {#if ...} ... {/if} for conditional, loop, await logic. This section can access variables declared or imported (i.e. stores) in the <script> section.

Props can be declared using the export keyword on a local variable.

<script>
    export let foo;
</script>

This approach as you can see is quite different from React's (or SolidJS's) functional approach. It can be confusing at first because it transforms/add a new meaning to existing JavaScript concepts, but it removes tons of boilerplate in the end.

What is reactive by default?

By default, in Svelte, everything in the <script> tag is executed once.

<script>
    export let name;

    console.log(name); // executed once
</script>
<h1>
    Hello {name} ! <!-- updated every time the prop changes -->
</h1>

But every variable specified in the markup will react to a value change.

Adding an internal state

Because assignments are reactive, updating a local variable will trigger a re-render and act as an internal state.

<script>
    import { onDestroy } from 'svelte';
    let elapsedSeconds = 0; // declaring a local variable

    function addSecond() {
        elapsedSeconds += 1; // triggers a re-render
    }

    const interval = setInterval(addSecond, 1000);

    onDestroy(() => {
        clearInterval(interval);
    });
</script>

<h1>Elapsed seconds: {elapsedSeconds}</h1> <!-- re-rendered every second -->

Adding reactive statements

In some situations, we need to trigger side-effects when a state changes. In Svelte, this works by prefixing the statement with $: JS label:

<script>
    let elapsedSeconds = 0;

    function addSecond() {
        elapsedSeconds += 1;
    }

    setInterval(addSecond, 1000);

    $: console.log(elapsedSeconds); // called every second
</script>

This not only works to trigger side-effects, but also works to compute “derived” variables:

<script>
    export let name;

    $: sentence = `Hello ${name}!`; // updated every time the name changes
</script>
<h1>{sentence}</h1>

Of course, there is a lot more to cover about Svelte, but here was a brief look at Svelte “opt-in” reactivity.

SolidJS

SolidJS is like the combination of React syntax and Svelte approach.

What is reactive by default?

Because SolidJS relies on JSX, the following code seems really familiar coming from React:

function Heading(props) {
    console.log(props.name); // executed once

    return (<h1>Hello {props.name} !</h1>); // updated every time the name changes
}

This can be misleading because the console.log statement would be called every time a prop changes in React, but not in SolidJS. Similar to Svelte, only the returned markup is reactive to a prop change.

Adding an internal state

Adding an internal state in SolidJS is also familiar. The internal state in SolidJS is called a signal (inspired by S.js) and is created using an API similar to the useState hook in React:

function Counter() {
  const [elapsedSeconds, addElapsedSeconds] = createSignal(0);

  const addSecond = () => addElapsedSeconds(elapsedSeconds() + 1);

  const interval = setInterval(addSecond, 1000);

  onCleanup(() => {
      clearInterval(interval);
  })

  return (<h1>Elapsed seconds: {elapsedSeconds()}</h1>);
}

Note: createSignal returns a tuple with two functions: a getter and a setter.

Adding reactive statements

Creating reactive side-effects is similar to the useEffect function in React.

function Counter() {
  const [elapsedSeconds, addElapsedSeconds] = createSignal(0);

  const addSecond = () => addElapsedSeconds(elapsedSeconds() + 1);

  setInterval(addSecond, 1000);

  createEffect(() => {console.log(elapsedSeconds())}); // called every second
}

The difference is that any signal specified in the scope of the given function will trigger the side-effect if its value is updated.

It is possible to make reactive dependencies explicit using the on helper.

Creating a derived state works by using the createMemo helper, which recalls the useMemo hook in React.

function Heading(props) {
    const sentence = createMemo(() => `Hello ${props.name()}!`);

    return (<h1>{sentence()}</h1>); // updated every time the name changes
}

With this quick overview, you can see how similar SolidJS and React are in terms of their API. This can be perplexing if we are not fully aware of the difference between the “opt-out” and “opt-in” reactivity strategies.

SolidJS can be confusing in other aspects, like how props reactivity is handled. Some statements, quite common in React, will not work:

// bad
const BasicComponent = (props) => {
 const { value: valueProp } = props;
 const value = createMemo(() => valueProp || "default");
 return <div>{value()}</div>;
};

// bad
const BasicComponent = (props) => {
 const valueProp = props.value;
 const value = createMemo(() => valueProp || "default");
 return <div>{value()}</div>;
};

Conclusion

“Opt-in” reactivity, is the cornerstone of fine-grained reactivity. The pattern Signal / Reaction / Derivation, allows frameworks like Svelte and SolidJS to optimize performances by managing only the needed subscriptions.

Evidence of this paradigm shift is the tweened/spring helpers in Svelte. These helpers produce values that can change every time the screen refreshes (generally 60 times a second). That means every subscriber (e.g. DOM node, derived value) is also updated at the same pace!

This can also be done in SolidJS. Here is an example where I update a signal for every animation frame.

This cannot be achieved in React. Third-party libraries like react-spring use custom animated variables with custom DOM components.