DEV Community

Cover image for 5 Ways SolidJS Differs from Other JS Frameworks
Ryan Carniato
Ryan Carniato

Posted on

5 Ways SolidJS Differs from Other JS Frameworks

Solid is a JSX templated UI Framework like React, that is reactive like Vue or Svelte. (Unfamiliar with Solid here is an introduction). Yet it has a few unusual quirks that are important to its design but many developers find really unexpected at first. This is even true for those coming from other "reactive" UI frameworks.

But trust me when I say there is a method to the madness. Let's look at how Solid is different and why this is a good thing.

1. Component's don't re-render

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

function A() {
  console.log("A");
  const [value, setValue] = createSignal(0);
  return <B
    value={value() + 1}
    onClick={() => setValue(value() + 1)}
  />;
}

function B(props) {
  console.log("B");
  return <C value={props.value - 1} onClick={props.onClick}/>;
}

function C(props) {
  console.log("C");
  return <button onClick={props.onClick}>{props.value}</button>;
}

render(() => <A />, document.getElementById("app"));
Enter fullscreen mode Exit fullscreen mode

When we first render this code it logs "ABC", but can you guess what we log when we click the button?

Nothing. Absolutely nothing. Yet our counter still increments.

This is by far the most defining part of Solid. Components do not re-run, just the primitives and JSX expressions you use. This means no stale closures or Hook Rules for those of you coming from React.

Like Vue or MobX we don't want to prematurely reference our reactive variables or destructure. But Solid has truly granular updates, unlike React, Vue, or Svelte. This means that components actually more or less disappear after the fact.

What looks like some simple binding is actually producing reactive streams through your view code, enacting updates cross-component with pinpoint accuracy. Your views not only look declarative, but they also behave that way.

How do we achieve this? Simply lazy evaluating all dynamic props. Look at what Component B compiles to:

function B(props) {
  console.log("B");
  return createComponent(C, {
    get value() {
      return props.value - 1;
    },

    get onClick() {
      return props.onClick;
    }

  });
}
Enter fullscreen mode Exit fullscreen mode

It just forwards the expressions down to where they finally get used. See the full example and compiled output here.

2. Proxies are readonly

This one can be a real mind-bender. Isn't reactivity about making things easy and it just works? It can be. But without careful control, it is easy to lose track of how changes propagate. This is part of the downside to reactivity when they describe it as "magic" with a negative context.

The core philosophy to reactivity is "what can be derived, should be derived". In so auto-tracking of dependencies which is often thought to be the problem, is not. The problem is in arbitrary assignments. We need to be explicit.

We've seen this before. Reducers like in Redux or events in state machines define set actions and operations to update our state. MobX has actions. The control from limiting these actions allows us to reason about what is happening.

More so nested reactivity like proxies is invasive. If you pass them as props or partials as props they too are reactive. They can be bound to different variables downstream to where an innocuous assignment is causing something on the opposite side of the app to update.

function App() {
  // create a mutable state object
  const state = createMutable({
    users: [{
      firstName: "John",
      lastName: "Smith"
    }] 
  });
  return <A users={state.users} />
}

function A(props) {
  <B user={props.users[0]} />
}

function B(props) {
  createEffect(() => {
    const person = props.user; 
    // do some stuff calculations
    Object.assign(person, calculateScore(person))
  })
  return <div>{person}</div>
}
Enter fullscreen mode Exit fullscreen mode

At this point with assigning calculateScore who even knows what new properties are present or if we updated an existing one, or if somewhere else is depending on certain fields to be there on the user.

We want to localize assignment or expose explicitly. The first is hard to enforce with the assignment operator unless you compile away reactivity like Svelte, read-only proxies are a fine second option. The key is read/write separation. A familiar pattern if you use React Hooks. Now we can pass around the ability to read without the ability to update.

const [state, setState] = createState({
  users: [{
    firstName: "John",
    lastName: "Smith"
  }]
});

state.users[0].firstName = "Jake"; // nope

// you need be passed the setter
setState("users", 0, { firstName: "Jake" }); // yes
Enter fullscreen mode Exit fullscreen mode

3. There is no isSignal/isObservable/isRef

Is this a fundamental part of the reactive system? Don't you need to know what you are dealing with? I'd rather you not.

The reason is simpler than you think. Every time you derive a value, make a reactive expression I don't want you to have to wrap it in a primitive. Solid doesn't wrap expressions you pass to child components in reactive primitives why should you?

// with memo
const fullName = createMemo(() =>
  `${user.firstName} ${user.lastName}`
);
return <DisplayName name={fullName()} />

// without memo
const fullName2 = () => `${user.firstName} ${user.lastName}`;
return <DisplayName name={fullName()} />
Enter fullscreen mode Exit fullscreen mode

These are almost identical except if <DisplayName> uses the name field multiple times the second will recreate the string whereas the first returns the same string until the name changes. But the overhead of the first is considerably more especially at creation time. Unless you are doing an expensive calculation it isn't worth it.

Most reactive systems encourage over-memoization. Reactive nodes store a reference of the value with each atom including derivations. This includes expressions you pass to child components. This is often really wasteful. You don't need to always wrap.

You might be wondering about how Components handle getting a signal or not, but we saw this before:

<>
  <DisplayName name={fullName()} />
  <DisplayName name={state.fullName} />
  <DisplayName name={"Homer Simpson"} />
</>

// compiles to:
[createComponent(DisplayName, {
  get name() {
    return fullName();
  }

}), createComponent(DisplayName, {
  get name() {
    return state.fullName;
  }

}), createComponent(DisplayName, {
    name: "Homer Simpson"
})];
Enter fullscreen mode Exit fullscreen mode

It's always props.name whether it is dynamic or not. Author your components based on your needs and let Solid handle the rest. See full example here.

4. Updates are synchronous

Ok, maybe this is expected. After all, you want your reactive library to be synchronous and glitch-free. Like if you update a value you expect it to reflect every in a consistent manner. You don't want the end-user interacting with out of sync information.

function App() {
  let myEl;
  const [count, setCount] = createSignal(0);
  const doubleCount = createMemo(() => count() * 2);

  return (
    <button
      ref={myEl}
      onClick={() => {
        setCount(count() + 1);
        console.log(count(), doubleCount(), myEl.textContent);
      } 
    }>
      {doubleCount()}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

It turns out different frameworks handle this differently. When you click they all log different things**.

React: "0 0 0"
Vue: "1 2 0"
Svelte: "1 0 0"
Solid: "1 2 2"

Which aligns with your expectations? Only 2 libraries are consistent here. Only React and Solid are showing you data that isn't out of sync. React doesn't read updated values until it commits its batch async. Solid has already updated the DOM by the next line. The other 2 choose between isolated reactive timing (Vue) and typical JS execution (Svelte). But they aren't glitch-free.

You might be thinking if there are multiple updates wouldn't Solid be inefficient. It is possible even though granular updates minimize it. We have a batch helper that records all updates and plays them back at the end. setState automatically batches its changes and changes are batched during effect execution.

onClick={() => {
  batch(() => {
    setCount(count() + 1);
    console.log(count(), doubleCount(), myEl.textContent);
  });
} 
Enter fullscreen mode Exit fullscreen mode

What does this log you ask?

"0 0 0". Inside batches Solid works similar to React to produce glitch-free consistency. See it in action here.

5. There is no unsubscribe

The last one is definitely unusual for people coming from other reactive libraries. Solid's reactive system while independent from the rendering does have some restrictions.

First, Solid is designed to automatically handle nested disposal of subscriptions on nested primitives it owns on re-evaluation. This way we can nest freely without memory leaks.

Like this example. Extracting the important parts:

const [s1, setS1] = createSignal(0);
const [s2, setS2] = createSignal(0);

createEffect(() => {
  console.log("Outer", s1());
  createEffect(() => {
    console.log("Inner", s2());
    onCleanup(() => console.log("Inner Clean"));
  });
  onCleanup(() => console.log("Outer Clean"));
})
Enter fullscreen mode Exit fullscreen mode

Updating s1 actually cleans both Inner and Outer effects and reruns Outer and recreates Inner. This is the core of Solid does its rendering. Component cleanup is just its nested reactive context being cleaned.

Second, Solid is synchronous but it still schedules updates. We execute effects after the rest of the reactive computations have settled. In that way, we can both handle things like mount hooks without being tied to the DOM, and do things like Concurrent Rendering where we hold off applying side effects until all async updates are committed. In order queue and execute synchronously we need a wrapper.

We do this with createRoot. You may never need this as render calls it for you and complicated control flows handle this under the hood. But if you ever wanted to create a subscription mechanism outside of the reactive tree, just create another root. Here's what a subscribe helper for Solid would look like:

function subscribe(fn, callback) {
  let dispose;
  createRoot((disposer) => {
    dispose = disposer;
    createEffect(() => callback(fn()));
  })
  return dispose;
}

// somewhere else
subscribe(() => state.data, (data) => console.log("Data updated"));
Enter fullscreen mode Exit fullscreen mode

See the working example here.

Conclusion

Solid might draw most of its attention from having such high performance, but a lot of consideration went into its design and identity. It might look familiar but it builds on the prior work that has come before it. At first, it does look a bit unusual but I hope you come to love it as much as I do.

Check out Solid on github: https://github.com/ryansolid/solid

** Cover image from Elena11/Shutterstock

** This analysis was performed while working on the new version of MarkoJS.

Oldest comments (6)

Collapse
 
shriji profile image
Shriji

The other 2 choose between isolated reactive timing (Vue) and typical JS execution (Svelte). But they aren't glitch-free.

in what context svelte is glitchy?

Collapse
 
ryansolid profile image
Ryan Carniato • Edited

It is possible to observe an intermediate state where not all things are consistent. All libraries take time to propagate change and JS isn't a language with this concept built-in but some libraries have guarantees to be glitch-free from an external standpoint.

Like with React an external observer never sees the state and DOM out of sync. Whereas with Svelte or Vue a well-timed microtask execution conceivably could. More so evident in models that try for async consistency like Concurrent Mode. In typical cases, this will never come up and things will be in sync by the time you observe them, but the console.log is evidence of that behavior.

Collapse
 
exelord profile image
Maciej Kwaśniak • Edited

Great Article!

Actually, what I would expect after batched update in solid is: 1, 2, 0.

I would expect that the signal has been updated but the dom updates has been scheduled till end of the batch. The Vue behaviour is kind of more intuitive and predictable for me.

However, worth to notice that not batched update in Solid is just on spot with my expectations! That why I love it! ;)

Collapse
 
ryansolid profile image
Ryan Carniato

Batched works like React. In all cases we maintain glitchless. This lets us do things like concurrent rendering, where committing values might need to wait on async. In the meanwhile we don't present inconsistent state.

Collapse
 
exelord profile image
Maciej Kwaśniak

I see :) I know that this makes sense from rendering PoV, however from developer PoV this is unexpected behavior. Many people see setCount(1) as count = 1. Being able to keep the value in sync with the updates could definitely improve DX.

Speaking of it, Im coming from Ember.js world where Glimmer tracking exactly does whats described. The memo, and the signal is changing, however the rendering is batched. I know Glimmer uses different concepts, but I think it would be still achievable in Solid.

The advantage of predictable code could have very positive impact and simplify a lot of problems.

Thread Thread
 
ryansolid profile image
Ryan Carniato

It's interesting, to think we might be able to locally apply changes without committing them to the outside world even in this scenario. It does bring a little bit of complexity. Observing the past like React feels a lot more consistent on the other side. The problem with Async consistency/Concurrent Rendering is that outside of the processing scope you have to be witnessing the past because it hasn't happened yet..

Solid has a transition API for this which uses batching under the hood. This is probably a little bit out there unless you are super familiar with future React features.

console.log("1", signalThatTriggersAsyncFetch()); // oldData
startTransition(() => {
  setSignalThatTriggersAsyncFetch(newData);
  console.log("2", signalThatTriggersAsyncFetch()); // oldData? newData?
});
console.log("3", signalThatTriggersAsyncFetch()); // has to be oldData;
Enter fullscreen mode Exit fullscreen mode

Right now Solid will show oldData for all 3 in synchronous executation, but if we immediately updated in local scope .. we'd see:

1 oldData
2 newData
3 oldData
Enter fullscreen mode Exit fullscreen mode

This is a bit confusing. Especially if something causes this transition to be cancelled and never complete. I'm not saying I can't be talked out of this behavior but it was a very safe position to take. And really only React seems to have really given this sort of thing consideration.