DEV Community

Jono M
Jono M

Posted on

Yet another post about SolidJS vs React

I've recently been working on a little personal finance app as a way of learning and experimenting with Bun and SolidJS.

I originally built it in RedwoodJS, but wasn't super happy with the TypeScript integration and found React a bit slow and hard to optimise on mobile. (Yes, it was probably my fault for writing a slow UI. But hey, it's a personal project, so sometimes it's more fun to try a different framework than optimise the current one.)

In the JavaScript ecosystem a new framework comes out every other day, so why bother with Solid? Nothing the framework does felt super revolutionary to me - Svelte already had a "compile JSX to DOM operations" approach, Vue already had an observable reactivity model, and there are many frameworks that look like React but are smaller and/or perform better. Where Solid stood out was in combining those into a simple(ish - we'll get to that), coherent and minimal-but-fully-featured package.

The cost of reactivity

If you're coming from React, the way Solid works takes a bit of wrapping your head around. But once you figure it out you have a moment of epiphany and wonder "why doesn't React work like this?"

Let's look at a simple example to illustrate this. In React you might write:

const Counter = () => {
  const [number, setNumber] = useState(0)
  const squared = number * number

  return (
    <button type="button" onClick={() => setNumber(number + 1)}>
      {number} squared is {squared}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

A couple of things about this are suboptimal.

  • Each time number gets updated, the whole function body is re-executed. This won't be noticeable now, but when you have a big component tree and something changes at the top, it can be tricky to avoid those extra milliseconds executing every function in the tree
  • Re-executing the function isn't the only work that will be done when number is updated. React will also build a whole tree of JavaScript objects for the elements on the page and use its Virtual DOM to create a diff and apply the updates to the page
  • There's a subtle issue with the onClick handler - because we create a new function every time this component is rendered, it will remove and re-add an event listener on the element every time. This is yet more unnecessary work. We can avoid that with useCallback, but in my experience that trick is not obvious or easy to remember for most developers

Let's try converting this to Solid then. Solid has createSignal which on the surface works pretty similarly to setState, so we can naively try this:

const Counter = () => {
  const [number, setNumber] = createSignal(0)
  const squared = number * number

  return (
    <button type="button" onClick={() => setNumber(number + 1)}>
      {number} squared is {squared}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Running it gives us:

Output: 0 squared is NaN

Well that didn't work. One key difference between Solid's createSignal and React's useState is that when we reference the state we need to use a function call, i.e. number() instead of number. This is because it's not just a number; it's an observable reactive value, which means when it changes Solid can detect the change and work out which exact parts of the page need to be updated with the new value.

So let's find and replace number with number() and see what happens.

Output: 4 squared is 0

That's a lot closer, but something is still not right. It's now incrementing the number correctly, but squared is not responding.

Let's extrapolate the same rule: using reactive values should look like function calls. So what if we make squared a function as well? Let's try squared = () => number() * number()

Output: 4 squared is 16

Success! Now we have the same reactive UI, written in a style which is subtly different but recognisably the same idea:

const Counter = () => {
  const [number, setNumber] = createSignal(0)
  const squared = () => number() * number()

  return (
    <button type="button" onClick={() => setNumber(number() + 1)}>
      {number()} squared is {squared()}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Try it on Solid Playground

In comparison to the three issues with React above:

  • The function body is only executed once
    • We know this because squared didn't update in our second attempt
  • When number is updated, only the text in the element will be updated; there's no big JavaScript object tree and expensive diffing calculation
    • We can sort of see this by looking at the generated output code on Solid Playground. There are a couple of calls to Solid's internal insert function, which basically watches a reactive value and does a single DOM update when it changes
  • The event handler is only created once
    • We can see this in the output on Solid Playground as well. There's a line looking like _el$.$$click = () => setNumber(number() + 1), and reading through the code around it we can see that it's in a function that's created and then immediately executed once

So Solid's reactivity works something like this:

  • Some Solid functions like createSignal return special reactive values
  • We can get the current value with a function call, and any UI which depends on that will be reactively updated
  • To compute values while maintaining reactivity, we need to create functions; then the function will be automatically re-executed when the underlying reactive state changes

Cool knock-on effects

This reactivity system also makes side effects and global state much nicer.

To do side effects, in React you might write something like:

useEffect(() => {
  console.log('number was updated to', number)
}, [number])
Enter fullscreen mode Exit fullscreen mode

But because Solid can watch reactive values and re-run functions when they change, in Solid you can just write:

createEffect(() => console.log('number was updated to', number())
Enter fullscreen mode Exit fullscreen mode

No need to specify a list of dependencies and add ESLint plugins to make sure you don't forget any. Just use those reactive values and you're done.

For global state, in React you probably need a helper function from a library like createGlobalState from react-use.

But in Solid, you can just call createSignal at the top level to create global reactive state - it's convention to call it in components but it works the same anywhere.

That seems so simple and consistent!

Well ... yes and no.

This function-call-style essentially exists to get around the limitations of JavaScript's primitive values (numbers, booleans, and so on). Once you start dealing with objects, constructs like Proxies exist which can fulfil the same purpose entirely transparently.

Because of this, props and stores (more complex state management) break this function call convention and just look like regular objects:

const Numberer: Component<{ count: number }> = (props) => {
  const [myContrivedStore, setStore] = createStore<{
    oneLess: number
    oneMore: number
  }>({
    oneLess: props.count - 1,
    oneMore: props.count + 1
  })

  createEffect(() => {
    setStore('oneLess', props.count - 1)
    setStore('oneMore', props.count + 1)
  })

  return (
    <div>
      One less: {myContrivedStore.oneLess},
      one more: {myContrivedStore.oneMore}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Either of these systems would be fine on their own, but in combination they can get confusing. It also means that instead of natively destructuring props you need to use the somewhat awkward splitProps helper function.

To be honest I wish everything just consistently used function calls, but that has its own drawbacks such as becoming increasingly awkward and heavy with deeply nested objects. I wonder if it would be possible to build an editor plugin that detects and highlights which variables are reactive (JavaScript is too dynamic to do that perfectly, but with TypeScript it ought to be possible to highlight typed variables at least).

Anyway, all this means Solid's reactivity system takes a bit of wrapping your head around - you need to get used to remembering which variables are reactive vs regular objects - but once you get the hang of it the framework feels more natural and expressive than React while also generally being only a few percentage points slower than vanilla JS.

Is React bad then?

No, of course not. There are huge numbers of companies using React in production and their apps work just fine. There's also a giant ecosystem around React, while Solid is still relatively young and doesn't have the same number of supporting libraries and frameworks.

But Solid does seem to be gathering momentum (at least looking at GitHub stars!) and it might just be in the sweet spot of "simple enough and similar enough to React to learn easily, but different enough to be worth learning".

SolidJS star history - sharply spiking coming into 2022/23

I can't say whether Solid is the right choice for your next giant enterprise project, but it's something to keep an eye on and I'm definitely rooting for it to succeed.

Top comments (0)