Engineering

May 1, 2024

A React Developer's Guide to Learning SolidJS

Tristan Dyer

Tristan Dyer

Our experience moving from React to SolidJS building Stashpad Docs.

A React Developer's Guide to Learning SolidJS

When we initially set out to build Stashpad Docs, one of the first decisions we had to make was whether to use React or to pivot to a different front-end framework. React was the default choice because the engineers at Stashpad are experienced React developers and already had used it to build Stashpad Lists.


SolidJS (colloquially referred to as Solid) had been on our radar for quite some time, and this project felt like a good opportunity to give it a shot. React had worked fairly well for us in Stashpad Lists, but we'd hit enough performance bottlenecks and quirky bugs that it felt worth our while to explore other options. We wanted to build something simple and blazingly fast, and the absence of a virtual DOM plus fine-grained reactivity aligned Solid with our goals quite well.


Since Solid's design was heavily influenced by React, the learning curve didn't look too daunting. We'd likely be able to pivot back to React quite easily if Solid ended up not working out (spoiler: it worked out).


While the Solid ecosystem is certainly less mature than React, we knew it wouldn't be an issue for us as we didn't anticipate using many third-party libraries. We even saw it as an opportunity to give back if we were to end up creating something that might have broader uses in the Solid community.


Once we'd decided to go with SolidJS, we all needed to learn Solid; which we essentially did as we went. The similarities with React make it easy to pick up Solid this way. The first step is to understand the few important differences between the libraries that have small, but important, consequences for how you write and structure code. The Solid docs are an excellent place to start. In this post, I'll deep dive into some of the more consequential concepts and the mental model I use when coding in Solid.


If you're coming from React, Solid will look and feel familiar since both libraries use JSX. This familiarity is useful because you don't have to relearn how to use JSX, and migrating code from React is fairly straightforward. However, the mental model that Solid requires has some key conceptual differences. You can break things in your code that otherwise would have worked in React if you don't have a grasp on these differences.


Writing and subsequently fixing lots of buggy Solid code led to an important realization for me: the places where I was introducing bugs were where Solid and React looked really similar. Wrongly, my assumptions about what the library was doing stemmed from my understanding of React. The patterns I'd habitualized over years of writing React code didn't translate directly to Solid. It was going to be extremely important to habitualize new patterns for Solid.


What helped me become a confident React developer and to adopt these useful patterns was gaining a conceptual understanding of what React was doing under the hood. So, naturally, I sought to do the same with Solid. For me, this meant a thorough reading of the Solid docs and a number of extremely informative blog posts (linked at the end of this post) by Ryan Carniato and others about the internal workings of Solid.


Comparing and understanding how the two libraries work under the hood–specifically in the places where they look similar–is what ultimately helped me the most in breaking React habits ("adapting" React habits might be apropos given how similar they are!) and making Solid "click" for me. So, that's the lens through which we'll explore Solid in this post.

If you're here because something's not working in some Solid code you've written and are looking for specific answers (e.g. why isn't my effect running when I think it should be?), check the code block at the beginning of each section. In each, I've given a scenario or two that the section might be helpful in addressing.

Components and Rendering

Read this section if you're wondering why:
- Some code in a component is not rerunning

As the main building blocks of an interface, components are a natural place to begin our comparison of the two libraries. We'll start by answering a simple question: what is a component?


In both SolidJS vs React, components are just functions. There's nothing inherently special about them as far as JavaScript goes: you call them, and they return a value. The JSX you typically see as part of the return statement is just syntactic sugar on top of JS, and components in React and Solid ultimately end up just returning an object.


So, for each library, let's look at what that returned object is and how it's used to render something onto the screen.

React

In React, a component is a function that returns an object describing what should be rendered. The object has properties like type, which will be "div" or "p" or "h1" or whatever type of element you created, and props, which will be an object containing any props you passed to the JSX element. To see for yourself, throw the following line of code into a React component somewhere and inspect it in your console:

console.log(<div></div>);

In React, you assemble these component functions into a tree and then pass the root component of that tree to a render function, which sets up a rendering loop.


Each component of your tree lives inside of that render loop, and any time the render loop is executed the component functions are rerun*, each returning a new object describing what should be rendered. The React library compares that description of what should be rendered with what currently is rendered, and makes any necessary changes so that the two match.


*This is a generalization. In reality, the render loop only reruns the parts of the component tree that might have changed in some way; the important concept to understand is that the render loop re-executes component functions.

SolidJS

In Solid, a component is a function that creates and returns a DOM element. Again, try it by inserting this snippet into a Solid component:

console.log(<div></div>);

The object printed to your console is a DOM element, just like one you'd get from, e.g., document.createElement('div').


In Solid, you assemble these component functions into a tree and then pass the root component of that tree to a render function, which executes each component exactly once. As such, it's helpful to think of a Solid component as a template. It's simply instructions for how to create a DOM node. There is no render loop in Solid, so once that DOM node is created, the component code is never re-executed.


TL;DR - A React component is a function called as part of a render loop; it returns an object describing what should be rendered. A Solid component is a function that is called once to create a DOM node.

Reactivity

Read this section if you're wondering why:
- Things aren't updating when I set a signal value.
- One of my effects isn't running when I think it should be.

Consider the following component, which is valid in both Solid and React:

let message = "Hello, World!"
const Message = () => {
    return <div>{message}</div>
}

Despite getting there in very different ways, as outlined in the previous section, each library will produce the same DOM tree. Similarly, neither library will do a single thing if you change the value of the message variable (ie. neither will react to the change), because it's not possible to implicitly know when the value has changed.


To provide this type of reactivity, each library provides "primitives" that are intended to be used for managing any kind of state whose changes need to be reflected in the rendered DOM. The interface for these primitives is quite similar between the libraries. In both, the most basic primitive for managing a piece of state gives you two things:

  1. a way to access the value, and
  2. a way to change the value.
    Correspondingly, these give the library the ability to track two critical pieces of information: (1) where the value is used, and (2) that the value has changed.
    How these two critical pieces of information are used by each library has implications for how the primitives are used in practice. This is where our mental models of the libraries begin to diverge.

React

Those last two paragraphs may have seemed abstract. Here’s a more concrete explanation to drive home the concept using something that will be very familiar to those coming from React: the useState hook.

const [message, setMessage] = useState('Hello, world!');

// Accessing the message variable state:
//   <div>{message}</div>
// Setting the message variable state:
//   setMessage('A new message!');

From the useState hook we're given two things:

  1. a way to access the state value, by using the message variable, and
  2. a way to set the value, by passing a new value to the setMessage function.
    Correspondingly, React can now track the two critical pieces of information mentioned above: where the value is used and that the value has changed. Let's see how it does each of these things.

Where the value is used.


Let's pretend we're the render loop. We're about to call a component function that might have some reactive state in it. Let's set up a way to track what state is used while the component function is executing. We’ll create an empty array, const context = [], and in the body of useState we’ll push the state’s value to that array. We execute the component function, and now have an array of values that were used inside of that component. The context array is now essentially a snapshot of the component state, and we've effectively associated each one of those values with the place where they were used.

That the value has changed.


Let's keep pretending we're the render loop, and we're about to call a component function that might have some reactive state in it. We're going to want to rerender the component any time some of that reactive state has changed, so let's create a flag, let needsRerender = false, and tell the useState hook to set the flag to true inside the body of each setter it creates. In doing so, we now have a system for notifying React that values inside of the component we're about to execute have changed.

Here's what the general concepts I've just outlined might look like in an implementation of useState (please consider this to be pseudocode):

let context = []
let needsRerender = false;

function useState(value) {
    // Track that the value was used
    context.push(value)

    const setState = (newValue) => {
        // Flag the component I'm used in for rerendering
        needsRerender = true;
        value = newValue
    }

    return [value, setState]
}

The astute reader might wonder why we need the context array at all? Shouldn't flagging the component as requiring a rerender be enough for reactivity? And the answer is that in this tiny example, it is! But React provides more than just useState; other hooks like useMemo and useEffect have dependencies -- values that when changed, trigger the hook to rerun. To determine if a specific dependency has changed, we need to know its previous value and current value, and context gives us the ability to make that comparison. Note that this isn't the sole reason for context, but it does give some insight into why we need it at all.
I digress. Try not to get caught up in the details too much; a basic understanding of this pattern is what is important. In particular, understand that when you call useState, React associates that call with the component function that called it.

SolidJS

In Solid, the "equivalent" primitive we use to create reactive state is called a signal:

const [message, setMessage] = createSignal('Hello, world');

// Accessing the message variable state:
//   <div>{message()}</div>
// Setting the message variable state:
//   setMessage('A new message!');

From the createSignal hook we're given two things: (1) a way to access the value, by calling the message function, and (2) a way to set the value, by passing a new value to the setMessage function.
Correspondingly, Solid can now track the two critical pieces of information mentioned above: where the value is used and that the value has changed. Having some déjà vu? That's okay, things are about to change.

Where the value is used.


Importantly, creating a signal doesn’t also mean "using" the signal, which is essentially what happens in React where, using the example above, a call to useState adds that state's value to the context associated with that component. Rather, a call to createSignal returns a function, referred to as a "getter", that returns the signal's value. A call to the getter function is what Solid uses to track where the value is used.
However, Solid still needs to set up an equivalent sort of system - one where there is a "context" set up to track the things that are about to happen. To provide what's called "fine-grained reactivity", Solid tracks specific contexts (called Subscribers). Importantly, JSX is one of these contexts, so let's look at a simple JSX example:

<div>{message()}</div>

Recall that JSX is just syntactic sugar over JavaScript; when compiled we end up with something akin to:

(() => {
    const div = document.createElement('div')

    const setText = () => {
        div.replaceChildren(document.createTextNode(message()))
    }

    // ... set up context here
    setText();
})()

Under the hood, Solid has similarly set up something like a context array before calling the setText function, so the message() call will associate the calling of setText with the message signal.

That the value has changed.


Just as in React, we use a function to set the value of the signal. Importantly, when a subscriber tracks that a signal's value has been accessed, it associates calls to the signal's setter with the subscribing function. Put more simply, when setMessage is called, the rerender function above will be re-executed, resulting in an immediate update of the associated DOM.

This is the essence of fine-grained reactivity in Solid. My hope is that your key takeaways from this section are twofold: (1) that reactive primitives in Solid are tracked where they are used, not where they are created, and (2) that to be tracked, reactive primitives must be used in certain specific contexts.


I highly recommend reading both the Intro to Reactivity and Fine-Grained Reactivity guides on the Solid website, as they cover much more in breadth and depth.


TL;DR - Conceptually, React and Solid both use "contexts" to understand where values are accessed and changed, enabling their reactivity. In React, the most basic primitive is useState and the context where it is tracked most generally is the component function. In Solid, the most basic primitive is createSignal and a signal's usage is tracked in fine-grained tracking scopes such as JSX.

Gotchas

While it's necessary to understand the fundamental differences between SolidJS vs React that I've outlined above, seeing how those differences materialize as bugs in your code will help drive home your understanding. Let’s look at some examples!

Bug #1: Code executes only once, and state never updates

const Counter = () => {
    const [value, setValue] = createSignal(0);
    const increment = () => setValue((prev) => prev + 1);
    console.log(value());
    return <button onClick={increment}>Increment</button>
}

Here, we touch on both concepts we've covered in this post. First, the Counter function is only executed a single time. So, the console log in the body of the function will also only be executed a single time. Second, we've set up a reactive primitive using createSignal, but we've called the accessor in a place that is not a tracking scope (the body of a component). For both these reasons, you'll only ever see the console print out the value 0.


To fix this, we could place the console log into a createEffect, which is a Solid primitive you can use to explicitly create a tracking scope. By doing so, any signals used within the effect are tracked, and setting their value will trigger the effect to be rerun. Let's see what this looks like:

const Counter = () => {
    const [value, setValue] = createSignal(0);
    const increment = () => setValue((prev) => prev + 1);
    createEffect(() => {
        console.log(value());
    })
    return <button onClick={increment}>Increment</button>
}

With this, the updated value will be printed every time the button is clicked.

Bug #2: An effect isn't always running when I think it should


If you're coming from React, the createEffect primitive will look similar to useEffect, with the notable lack of a dependency array. Indeed, the two primitives are used to achieve the same outcome. However, because Solid signal values are accessed using function calls (rather than variables), Solid can leverage those function calls to track dependencies. This turns out to be both extremely convenient and a way to introduce a sneaky bug if you aren't careful. Take a look at this code:

const [a, setA] = createSignal(true);
const [b, setB] = createSignal(false);

createEffect(() => {
    if (a()) {
        console.log('a')
        return;
    } 

    if (b()) {
        console.log('b')
        return;
    }

})

When first run, you'd expect to see just the letter 'a' printed in your console, and you'd be right. You also might expect that this effect is now tracking both signals and that calling setB(true) would rerun the effect and still print only the letter 'a'. This, however, is not correct!
Until the value of the signal a is set to true, the b signal will not be tracked by the effect. Recall that it’s calling the signal's accessor that sets up reactivity for that signal, and then follow the execution flow through the first execution of the function. The second if statement is never reached, so the b accessor is never called and therefore b is not reactive.


If you need to set up control flow logic that looks something like the above, Solid provides on (docs), which can be used to explicitly define dependencies in the same way you would with the React dependency array. Here's how we could use it to fix this bug:

const [a, setA] = createSignal(true);
const [b, setB] = createSignal(false);

createEffect(on([a, b], () => {
    if (a()) {
        console.log('a')
        return;
    } 

    if (b()) {
        console.log('b')
        return;
    }

}))

Bug #3: My props aren't reactive


A common pattern in React is to destructure your props at the top of your components, for example:

function Message(props) {
    const { text } = props;
    return <div>{ text }</div>
}

However, in Solid, destructuring your props will break reactivity. Early on when you're learning Solid this can lead to a fair number of bugs, simply because you've forgotten you're not supposed to do this. I found understanding why it breaks reactivity helped me break the habit, so let's take a basic look at what's going on under the hood.


The props variable passed to a component, as you know, is an object with key-value pairs corresponding to the props that were provided to the component in JSX. Following the example above, the code <Message text="Hi"/> will result in a props object that looks like { text: "Hi" }.


Before passing it into your component function, however, Solid proxies the props object. Proxies are quite an interesting part of JavaScript; you can think of a proxy as an object that acts as a stand-in for another object, allowing you to redefine how certain fundamental operations work for that other object.


The fundamental operation we're particularly interested in is the "get" operation. You may not realize it, but any time you use . or [] to access an object's value, you're calling a get function. When you create a proxy of an object, you have the opportunity to define how the get function works for that object.


So, when Solid creates the props object, it also creates a signal for each value the object will contain. Then it creates a proxy, redefining the get function so that when . or [] is used to get a value from the object, the value returned is simply the return value of calling a signal accessor. Calling the accessor, of course, is what sets up reactivity for that specific property.


When you destructure props, you call the getter for each property where you do the destructuring. This is typically at the top of the component function and notably not a tracking scope. Hence, reactivity is broken!

Conclusions

The concepts I've covered in this blog post only scratch the surface of the technical differences between Solid and React. Additionally, my descriptions are not intended to give a perfectly accurate representation of the actual implementations. I've found that places where SolidJS and React look very similar tend to be the places where sneaky bugs pop up. So, the concepts in this article simply serve as a mental model that highlights where the two libraries will behave differently.


Breaking out of our React habits and making the switch to Solid was actually pretty painless while building Stashpad Docs. The documentation for Solid is excellent. There are plenty of resources that help ease the learning curve.

Overall we're extremely happy to be using Solid in Stashpad Docs; the performance is rock-solid and the simplicity and elegance of the library have made writing Solid code a breeze. Hopefully, you've come away from this post with a better understanding of Solid and are excited to try it (and Stashpad Docs) out for yourself!

Make your Work
Note-Worthy

Also available for .

Download Stashpad

Stay In Touch

Subscribe to the Stashpad Newsletter to receive occasional product updates and company announcements.