SolidJS: Reactivity Unchained
AI Generated Video Summary
1. Introduction to Solid.js
Solid on the surface at least is a very React-like framework. It values the same things. Unidirectional flow, read write segregation, and immutable interfaces. It's built on the same building blocks. Hook-like primitives, function components, and JSX. And it has a lot of similar features. things like portals and fragments, suspension transitions, streaming SSR, and some others too. That aren't in React, like custom directives and data fetching.
2. Building a To-Do App in React
Your first inkling might be to build your to-do app in a framework like React. But as your program grows, you apply optimizations like memo and use callback. Let's compile it. This is compiled output, similar to what a framework like Svelte would do.
Your first inkling might be to build your to-do app in a framework like React. Kind of like this. Declare some state, and wire it up. But on any change, even unrelated to the to-do list, you'd be re-rendering the whole list. But maybe that's okay. That's why there's a Virtual DOM, to make sure this isn't as expensive. But maybe you still want to optimize. As your program grows, you apply your optimizations, and things may start looking like this. Maybe you add a filter, and some theming.
And there's nothing innately wrong with this. But now we are annotating things with memo, and use memo, and use callback. And adding our dependency arrays, and ensuring that everything runs exactly how we want it to. But it's a bit of a departure from where we started.
So... Let's compile it. And this might be what you'd end up with. To be fair, this is not the code you'd write, this is compiled output, kind of pseudo-code, from an experimental compiler React team is working on. But it isn't actually all that different from what a framework like Svelte would be doing. It's a bunch of shallow referential equality checks, that at every decision point, rerun when your component is marked as needing an update. Common ground is a component update state, then runs an update function, and checks against these memorized values, as shown here in this memcache.
3. Rethinking Performance and Reactivity
What if we only ran what was needed to be run, and didn't rely heavily on compilation? What if the boundaries of our components didn't dictate the performance of our web applications? To do that, we need to kind of go back to the beginning.
But what if we didn't? What if we only ran what was needed to be run, and didn't rely heavily on compilation? What if the boundaries of our components didn't dictate the performance of our web applications? To do that, we need to kind of go back to the beginning. And I'm going to go all the way back to the beginning. Remember, the first program you wrote. It was a Hello, World. You were able to set some data in a variable and log it on the console. And it didn't take too long before you realized, well, you could set a new value to that variable and log it again. You know, this is the beginning of programming.
4. Understanding Reactivity and Primitives
Then you realized, well, that's a lot of repetition, so let's extract that out into a function. Reactivity is based on primitives. The first primitive is a signal, which is a wrapper over a value. We replace the assignment with set name and call it as a function. Another primitive is create effect, which allows us to interact with our world whenever a signal changes. This is achieved by using the runtime stack. Fine-grained reactivity is the key to understanding how this works. A fine-grained reactive library can be created in about 50 lines of code.
Then you realized, well, that's a lot of repetition, so let's extract that out into a function. Maybe a greet function here, and we can have a seter name and then call greet, and then seter name and call greet again. And this is all great, but at a certain point, maybe you're like, every time I set the name, I want it to greet without having to call it. And that's where reactivity comes in.
That in itself, to be fair, is not terribly interesting. So we have another primitive we're going to choose here called create effect. And what effects are is they allow us to interact with our world whenever a signal changes. In this case, we're going to take our console log and we are going to put in the effect and have it listen to name. This lets us clean up the rest of this code, essentially. So now we just create our signal for the name and then create the effect that runs once. And then when we set the name again, it runs again. And again, it does this because we call names a function that lets us intercept reading that value. And this can actually extend outside of the effect itself because this is not a compiler trick. This is completely runtime. So we can make a like uppercase and now we can basically call our name in here and change it to uppercase, stick it in our effect. And because it's just using the runtime stack, we can see hello world and hello solid js now in capital. So you might be wondering how this works. And it all comes down to this idea of fine-grained reactivity. Creating a fine-grained reactive library can be done in about 50 lines of code.
5. Understanding Signals and Effects
At its core, a signal is a getter and setter function pair that close over a value. We update our signals to check for current observers and notify them when something changes. The implementation for get current observer uses a stack to track the currently running context. Effects are executed immediately, pushing themselves onto the stack, adding themselves to subscriptions, and then popping off the stack. With this foundation, we can build other primitives like createMemo, createStore, and createResource.
I'm going to simplify it even further here for demonstration purposes, but it all starts with signals like we saw. And at its core, you just view a signal as a getter and setter function pair that close over a value. Of course, there's a little bit more to it than that. And for that, we're going to need subscription.
So let's update our signals, do a bit more. Now on read what we want to do is check if there's a current observer. And if there is, we're going to add it to a new subscriber set that we create when we create our signal. On right, we update our value still, but now we actually iterate over those subscribers and call them to basically notify them that something has changed. And that something that needs to be notified are our effects, which are the other side of our equation.
Here you can see the implementation for get current observer. What we have is a stack. This context is just an array, global context. And we just grab whatever's atop the array to see what's currently running. For our effect itself, when it's created, it's executed immediately. And then it goes through the cycle of clean up dependencies or subscriptions, push itself onto that stack, so that when we execute the provided function, it's there and can be added to the subscriptions. And finally, it pops itself off the stack. I'm gonna put the code side by side, so you can kind of see this better as we go over our example. Essentially, we create our signal, like our name signal, and it returns our read and write functions. Then we create our effect. It executes, pushing itself onto that stack. Then it runs the function and it reads from our name signal, at which point it sees the current observer, which is that effect, and adds it to its subscribers. Then it logs it to the console and the effect finishes running, popping itself off the stack. Sometime later, our signal is updated, which sets the new value and then executes our list of subscribers. In this case, it's that effect, which executes it again, cleaning up the dependencies and we just start the whole cycle all over again. And that's really it. From there, we can build a foundation for other primitives. A lot of them are not essential. They can be used as needed, but an example of a few important ones that ship with Solid are createMemo, which can be used to cache expensive computations, createStore, which is a proxy which enables deep-nested reactivity, and createResource, which is our first-party primitive for data fetching and suspense. But enough on reactivity for now. Let's get back to our example.
6. Creating DOM Elements and Using JSX in Solid.js
When we teach reactivity, we create a DOM element, set text on it, and attach it to the DOM. We put the text setting in an effect, so it updates the DOM when the name changes. We create a button with a click handler using vanilla DOM APIs. Solid allows writing JSX, which is just DOM elements. We can abstract the code into a function and return the elements as an array. We can inline the JSX, remove unnecessary code, and format it with prettier. We can replace the array with a fragment, making it cleaner.
When we teach reactivity, we always use console logs, but let's do something a little more substantial here. Let's actually create a dom element this time and see what it does. So what we're going to do here is we're going to create a header and we're going to set some text on it and then we're going to attach it to the dom.
Okay and then we're actually going to take setting that text on it and put it in the effect. So now whenever the name changes, it will update the dom. In this case, we set the solidjs right away. So you don't even see it. We just see hello solidjs because it updated right away. So deal with that.
Let's create a button and we'll put the setter inside a click handler. So we're just using vanilla dom APIs here so that we can make it work. And there we go. Now our Hello World has a greet button and when we click greet, it changes it to hello solidjs. But I mean, this is a lot of code. Who wants to write all that vanilla js? Wouldn't it be great if we could just write something like jsx? And in solid, you can because jsx and solid is just DOM elements. So this is a real HTML button element we're creating here and we're just going to put a click handler on it, clean up that code, and our example still works. And we can give the header the same treatment. The one difference here though is that the header has a reactive statement and Sol's compiler can actually see that we're calling a function in the expression so it knows to wrap it automatically in effect. And when we do that, sure enough, it still works. And it's a little bit cleaner here but we don't really write our apps this way. So let's abstract this into a function or a component so to speak. But this is just a function. We can clean this up a little bit and maybe return the elements as an array. And then we can just call our function and just attach it. And as you can see, it clearly still works. But let's inline the JSX a little bit more, clean up a bit more, remove all of that, and then format it with prettier. And here we go. Okay. We can actually replace the array with a fragment because that's all fragments are in Solid. And this is starting to look pretty good.
7. Rendering Components and Sharing State
Solid's components only run once. Each component can wrap over its own state. We can share state by pulling signals out of the component. Composition in Solid allows for sharing state and passing props. Only the header text is updated.
Last thing we really have to deal with now is just how we're attaching to the DOMs. So Solid provides a render function and we can just render our greet component and there we are. This looks like any modern framework.
But there is one big difference here. Solid's components only run once because as you saw, we were just calling this function. So if I put a console log in here, like greet, let's say greet. And you'll see a log when I press it, it's not logging again. It only logs once. Okay. So let's do that one more time. Okay. Here we are. Hello world. See greet, press it. Only once. And yeah.
8. Solid Rendering and Component Props
In Solid, the handling of component props and JSX is done lazily, allowing for efficient evaluation. Only the necessary effects are executed, minimizing re-execution. Solid provides helper functions in the form of components, which are composable and can be used for special handling of rendering, such as pagination and virtualized lists.
So this app console log will show that even when we update it, it's not gonna log it again. And actually to make that more clear, I'm gonna take this set name and I'm going to actually have it increment differently for each component, even though it's using the shared state. Even though we're kind of passing everything through, when we actually do an action, there's only two effects on this whole page and those are for both headers to update that text.
Okay. So what's going on here? Well, it's because of the way we handle component props. In solid, the only thing we actually transform is the JSX. And for components, when we see an expression that contains JSX or something that could be reactive, like a function call, we wrap it in a getter, like this get name. And what this does is allow it to be evaluated lazily. It's very similar to what we do with DOM elements, where if we see something that could be reactive, we wrap it in an effect. Basically, everything is lazily evaluated all the way to the final effect, which actually does the work. And as I said, there's only two effects in that demo. The only work being done is updating that single text node inside each header. Nothing else re-executes.
The last thing you probably need to know about solid rendering is that we're dealing with real DOM nodes. And creation can be wasteful. So we needed to do some special handling for things like lists. A simple array.map would never run over every item and map new results. And we don't want to create all new DOM nodes every time we add something to a list or sort it. So we need to do something a little special here. For solid, we have helper functions. But we chose to ship them in the form of components as they fit well with our patterns and they are very composable like the rest of the primitives in our system. What I mean by composable? Well, pretend now we need to update this. We only want to show 10 items on each page. We need to paginate. Luckily, someone has created this component for that. So what do we do? Well, maybe we import it and now we just change our for component. And as you can see, it's exactly the same pattern. Lists become paginated or virtualized lists. Conditionals become layouts or suspense or error boundaries. It's all the same thing.
9. Fine-grained Rendering and Reactive Advantage
Solid allows for fine-grained rendering and updates without the need for additional optimizations like memos or dependency arrays. Components run once, templates compile to real DOM nodes, and state is independent of components. SOLID offers a low abstraction over the DOM, providing the freedom to interact directly when needed. The performance is consistently good, and it offers a reactive advantage. Give SOLID a try to experience this freedom in organizing your code.
You know, if you don't like solid's for component, it's just a component. It's runtime, you can write your own. This is really powerful stuff. And armed with this, we can kind of return all the way back to the beginning with our to do example.
So I've recreated that example in solid that we saw at the beginning, and we have a to do list and we have a handler that can, we have set up our state of our to do's and we have a handler that will update our to do's to done. And what we've done here is we're actually passing in a visibility filter and a color picker. And this gets passed all the way through to our add to do's to kind of set the theming. So this adds a little bit more complexity or you'd think, but in solids case, without the need for adding any memos or use callback or basically, or dependency rays or basically anything additional, we can just take our list, filter it based on that prop and only have the things that need to update, update. For example, when we click this three, now we're gonna update it to to-do or when we unclick it, we update it. When we change the filter, it gets called. But as you can see, updating the to-do did not cause the filter to get called. And if we go back, well, we're gonna have to recreate those three elements. So yes, we called the filter again and we updated three times, but this prop getting passed through here for the theme color, us changing this, doesn't cause the to-do updates or the filter to update. It just works. And we did this all without any kind of memo. Actually, the hardest part about making this example was trying to show what updated because so little does. This is what happens when you have fine-grained rendering and updates.
For me, I call this the reactive advantage. Basically, components run once. There's no hook rules or stale closures or dependency arrays for that matter. Templates compile to real DOM notes. This is super low abstraction over the DOM. It means that you have that escape hatch. You need to do something with the DOM, it's right there. But most importantly state is independent of components. Component boundaries are for your sake how you want to organize your code. It's not about performance. The performance is good regardless. So if you want to experience the freedom yourself, maybe give SOLID a try.