Anatomy of a Signals Based Reactive Renderer
We are going to implement from scratch the core parts of a signals based reactive web renderer: rendering, placeholders, node creation and node disposal.
In case you dont know what Signals are, here there are a few links:
- Ryan K Carniato - Revolutionary Signals (video)
- A Hands-on Introduction to Fine-Grained Reactivity
- Making the Case for Signals in JavaScript
# Introduction
With Signals, it may be a bit confusing at first how things work. This is an attempt to illustrate the rendering aspect, with what I learned writing pota and reading dom-expressions and voby.
For the Signals library, we are going to use flimsy, which is a small and educative library that implements and documents the reactive core of Solid. Flimsy is around 212~ LOC, code worth a read. The renderer we are going to implement, let's call it clumsy, is around 100~ LOC.
The explorations will get progressively more complicated revisiting and updating what we already wrote. While this doesn't cover everything, it's an initial kick for further articles.
# insert
We need a function with the purpose to insert Nodes in a parent.
# create
We also need a function to create nodes from any kind of data. The created nodes are inserted in the document with insert
That's a bit too simplistic, let's make it more fancy by adding more data-types
- Avoiding null and undefined by returning early
- Allowing Nodes by inserting them directly
- Allowing functions, by calling the function and recursing with the returned value
# Placeholders
Now let's add support for Promises, by checking if then it's in the child, when child it's anobject.
As the promise is async and fulfills at a later point in time, our child will be created at an unexpected position in the document.
To solve this problem, we can create an invisible placeholder, add it to the document right away, and hold it to use as a marker for the place on which the children should have been inserted once the promise fulfills.
We will have first to update our insert function to allow inserting nodes at a relative position (using placeholders), instead of just appending to the parent.
Now we can define a function to create placeholders.
Then we need to update the code that inserts the promise to use a placeholder
Putting everything together, it should just work.
However, we are using a visible span to illustrate how a placeholder works. We can use a comment or an empty text node instead, to hide it from the screen.
For debugging, a comment it's useful because it's visible on the developer tools and can carry debugging text. Once we are done, we can switch to an empty text node to make it invisible in the developer tools.
# Signals and Reactivity
Now that we know how to render functions, and keep stuff in position in the document we can introduce Signals
A Signal is a function that holds a value that may or may not change over time. If you are rendering the result of a signal and the value updates, you want to render it in the same position it was, just like we did with the promises.
However, it's rendering but not updating its value when we click the button. Let's take a look to the code we use to render a function
We will need to use an effect, so when the value of the signal changes, what has been rendered updates too. Fine-Grained updates.
An effect it's a function that tracks signals reads, and re-runs but only when the signals change.
In this case, child is the signal that we want to track
A renderEffect it's just like an effect, but immediately executes. Reactive systems may choose to hold the execution of regular effects, to collect first, useful information that improves the system performance.
The order on which effects execute is not warranted by the reactive system, again, to improve its performance.
By immediately executing with a renderEffect, we ensure the correct order of execution, allowing us to render the document in the order that we expect.
Testing our newly added renderEffect, now the signal change should be updating the document
To remove the old node, we can use a cleanup function, which runs when the tracking scope its disposed.
An effect/renderEffect creates a tracking scope, that keeps tracks of which signals have been read. When any of the signals read change, the reactive scope is invalidated, cleanup callbacks run, and the effect function is re-executed
We can ensure the old node is removed by adding cleanup on our insert function. That way, when the tracking scope is invalidated, our node is removed from the document.
Now with the added cleanup it should work as expected
Well, not that fast, the number is moving to the end of the document, a problem similar to the promises we saw before. Placeholders to the rescue.
Let's try again to see
We wrote two of the common internal functions, insert and create, while considering their position in the document with placeholders.
# render
The render function, it's the one that creates a detached tracking scope (a root that doesn't dispose when the parent scope disposes), to hold all nodes created under it. It can be disposed, by calling the function that returns, unmounting all the nodes we created with it.
# Source Code
The code used of this article it's on github. For a real world reactive renderer implementation, you can take a look at pota, voby and dom-expressions.
If you enjoy this topic you can join SolidJS Discord to chat!
Clumsy, partner in mischief with flimsy