VanJS: Advanced Topics
Everything should be made as simple as possible, but not simpler.
-- Albert Einstein
DOM Attributes vs. Properties
In tag functions
, while assigning values from props
parameter to the created HTML element, there are 2 ways of doing it: via HTML attributes
(dom.setAttribute(<key>, <value>)
), or via the properties of the created HTML element (dom[<key>] = <value>
). VanJS follows a consistent rule that makes sense for most use cases regarding which option to choose: when a settable property exists in a given <key>
for the specific element type, we will assign the value via property, otherwise we will assign the value via attribute.
For instance, input({type: "text", value: "Hello 🍦VanJS"})
will create an input box with Hello 🍦VanJS
as the value of the value
property, while div({"data-index": 1})
will create the tag: <div data-index="1"></div>
.
Note that, for readonly properties of HTML elements, we will still assign props
values via setAttribute
. For instance, in the code snippet below, the list
of the <input>
element is set via setAttribute
:
const Datalist = () => div(
label({for: "ice-cream-choice"}, "Choose a flavor: "),
input({
list: "ice-cream-flavors",
id: "ice-cream-choice",
name: "ice-cream-choice",
}),
datalist(
{id: "ice-cream-flavors"},
option({value: "Chocolate"}),
option({value: "Coconut"}),
option({value: "Mint"}),
option({value: "Strawberry"}),
option({value: "Vanilla"}),
)
)
NOTE: for Mini-Van, since 0.4.0
, we consistently assign the props
values via setAttribute
for all property keys in tag functions. This is because for SSR (server-side rendering), which is Mini-Van's primary use case, setting the properties of a DOM node won't be visible in the rendered HTML string unless the action of setting the property itself will also set the corresponding HTML attribute (e.g.: setting the id
property of a DOM node will also set the id
attribute). This is helpful as input({type: "text", value: "value"})
can be rendered as <input type="text" value="value">
in Mini-Van but would be rendered as <input type="text">
if we set the property value via DOM property.
State and State Binding
Why can't states have DOM node as values?
We might be prompted to assign a DOM node to a State
object, especially when the State
object is used as a State
-typed child node. However, this is problematic when the state is bound in multiple places, like the example below:
const TurnBold = () => {
const vanJS = van.state("VanJS")
return span(
button({onclick: () => vanJS.val = b("VanJS")}, "Turn Bold"),
" Welcome to ", vanJS, ". ", vanJS, " is awesome!"
)
}
Demo:
In this example, if we click the "Turn Bold" button, the first "VanJS" text will disappear, which is unexpected. This is because the same DOM node b("VanJS")
is used twice in the DOM tree. For this reason, an error will be thrown in van-{version}.debug.js
whenever we assign a DOM node to a State
object.
State granularity
Whenever possible, it's encouraged to define states at a more granular level. That is, it's recommended to define states like this:
const appState = {
a: van.state(1),
b: van.state(2),
}
instead of this:
const appState = van.state({
a: 1,
b: 2,
})
More granular State
objects can help state bindings be more locally scoped, which make reactive UI updates more efficient by eliminating unnecessary DOM tree construction and replacement.
Minimize the scope of DOM updates
It's encouraged to organize your code in way that the scope of DOM updates is minimized upon the changes of State
objects. For instance, the 2 components below (Name1
and Name2
) are semantically equivalent:
const name = van.state("")
const Name1 = () => div(() => name.val.trim().length === 0 ?
p("Please enter your name") :
p("Hello ", b(name)),
)
const Name2 = () => {
const isNameEmpty = van.derive(() => name.val.trim().length === 0)
return div(() => isNameEmpty.val ?
p("Please enter your name") :
p("Hello ", b(name)),
)
}
But Name2
's implementation is more preferable. With Name1
's implementation, the entire <p>
element will be refreshed whenever name
state is updated. This is because the entire <p>
element is bound to name
state as specified in the binding function. On the other hand, with Name2
's implementation, the <p>
element is only refreshed when name
state is changed from empty to non-empty, or vice versa, as the <p>
element is bound to derived state - isNameEmpty
. For other changes to name
state, only the Text node
inside the <b>
element will be refreshed.
Advanced state derivation
道生一,一生二,二生三,三生万物
(Tao derives one, one derives two, two derive three, and three derive everything)
-- 老子,道德经
A broad set of advanced state derivation (derived states and side effects) can indeed be defined with van.derive
, as illustrated in the following piece of code:
const fullName = van.state(localStorage.getItem("fullName") ?? "Tao Xin")
// State persistence with `localStorage`
van.derive(() => localStorage.setItem("fullName", fullName.val))
// Defining multiple derived states
const firstName = van.state(), lastName = van.state()
van.derive(() => [firstName.val, lastName.val] = fullName.val.split(" "))
const elapsed = van.state(0)
setInterval(() => elapsed.val += .01, 10)
// Same as `elapsed`, but delay the state propagation by 1s
const delayed = van.state(0)
van.derive(() => setTimeout(v => delayed.val = v, 1000, elapsed.val))
// Same as `elapsed`, but throttle the state update to every 100ms
const throttled = van.state(0)
setInterval(() => throttled.val = elapsed.val, 100)
// Generate a data stream for all value updates of a given state `s`
const streamOf = s => {
let resolver
van.derive(() => resolver ? resolver({value: s.val, done: false}) : s.val)
return {
[Symbol.asyncIterator]: () => ({
next: () => new Promise(resolve => resolver = resolve)
})
}
}
(async () => {
// To subscribe the data stream
for await (const v of streamOf(throttled)) {
console.log("elapsed: ", v)
}
// You can also chain the data stream with `map`, `filter`, etc. by integrating with
// rubico (https://rubico.land) or wu.js (https://fitzgen.github.io/wu.js/).
})()
Conditional state binding
In State
-derived properties and State
-derived child nodes, it is guaranteed that the binding function will (only) be triggered when the dependency states change. This is true even for complex binding functions, who have different dependency states under different conditions.
For instance, the binding function () => cond.val ? a.val + b.val : c.val + d.val
will (only) be triggered by updates of state a
, b
and cond
if cond.val
is true, and will (only) be triggered by updates of state c
, d
and cond
if cond.val
is false. This can be illustrated with the code below:
const ConditionalBinding = () => {
const formula = van.state("a + b")
const a = van.state(1), b = van.state(2), c = van.state(3), d = van.state(4)
const triggeredTimes = new Text(0)
return div(
div(
"formula: ",
select({oninput: e => formula.val = e.target.value},
option({selected: () => formula.val === "a + b"}, "a + b"),
option({selected: () => formula.val === "c + d"}, "c + d"),
),
" a: ",
input({type: "number", min: 0, max: 9, value: a, oninput: e => a.val = Number(e.target.value)}),
" b: ",
input({type: "number", min: 0, max: 9, value: b, oninput: e => b.val = Number(e.target.value)}),
" c: ",
input({type: "number", min: 0, max: 9, value: c, oninput: e => c.val = Number(e.target.value)}),
" d: ",
input({type: "number", min: 0, max: 9, value: d, oninput: e => d.val = Number(e.target.value)}),
),
div("sum: ", () => {
triggeredTimes.textContent = Number(triggeredTimes.textContent) + 1
return formula.val === "a + b" ? a.val + b.val : c.val + d.val
}),
div("Binding function triggered: ", triggeredTimes, " time(s)"),
)
}
Demo:
Conditional state binding works for derived states and side effects registered via van.derive
as well:
const ConditionalDerive = () => {
const formula = van.state("a + b")
const a = van.state(1), b = van.state(2), c = van.state(3), d = van.state(4)
const triggeredTimes = new Text(0)
const sum = van.derive(() => {
triggeredTimes.textContent = Number(triggeredTimes.textContent) + 1
return formula.val === "a + b" ? a.val + b.val : c.val + d.val
})
return div(
div(
"formula: ",
select({oninput: e => formula.val = e.target.value},
option({selected: () => formula.val === "a + b"}, "a + b"),
option({selected: () => formula.val === "c + d"}, "c + d"),
),
" a: ",
input({type: "number", min: 0, max: 9, value: a, oninput: e => a.val = Number(e.target.value)}),
" b: ",
input({type: "number", min: 0, max: 9, value: b, oninput: e => b.val = Number(e.target.value)}),
" c: ",
input({type: "number", min: 0, max: 9, value: c, oninput: e => c.val = Number(e.target.value)}),
" d: ",
input({type: "number", min: 0, max: 9, value: d, oninput: e => d.val = Number(e.target.value)}),
),
div("sum: ", sum),
div("Binding function triggered: ", triggeredTimes, " time(s)"),
)
}
Demo:
Self-referencing in side effects
The barber is the "one who shaves all those, and those only, who do not shave themselves". The question is, does the barber shave himself?
-- Bertrand Russell, Barber paradox
Sometimes side effects could lead to trick situations:
const CheckboxCounter = () => {
const checked = van.state(false), numChecked = van.state(0)
van.derive(() => {
if (checked.val) ++numChecked.val
})
return div(
input({type: "checkbox", checked, onclick: e => checked.val = e.target.checked}),
" Checked ", numChecked, " times. ",
button({onclick: () => numChecked.val = 0}, "Reset"),
)
}
Prior to VanJS 1.3.0, the code above is problematic. The intention of the code is to count the number of times that the checkbox is checked. The code:
van.derive(() => {
if (checked.val) ++numChecked.val
})
defines the side effect to increment numChecked
whenever checked
state is turned to be true
. However, since ++numChecked.val
de-sugars to numChecked.val = numChecked.val + 1
, the side effect actually depends on numChecked
state as well. As a result, when the Reset
button is clicked, it updates the numChecked
state, which leads to the side effect to increment numChecked
state, which will further trigger the same side effect and increment numChecked
, over and over again - an endless loop. Eventually a stack overflow error will occur to stop the loop, leaving numChecked
state ending in an arbitrary number.
VanJS 1.3.0 adjusts the dependency detection mechanism in this situation to avoid the problem. That is, if we're setting the val
property of some state inside a binding function (be it in van.derive
, for state-derived properties, or for state-derived child nodes), that state will not be consider as a dependency of the binding function, even if its val
property is being read there. The adjustment is aimed to avoid the self-referencing problem discussed above, making it impossible to trigger an side effect to update a state that re-triggers the same side effect again. Thus in VanJS 1.3.0 or later, the code above has the correct behavior - clicking the Reset
button will just reset numChecked
to 0
.
You can try out the program before and after the 1.3.0 update:
Garbage Collection
There is garbage collection mechanism implemented in VanJS to recycle obsolete state bindings. To illustrate the necessity of garbage collection, let's take a look at the code below:
const renderPre = van.state(false)
const text = van.state("Text")
const TextDiv = () => div(
() => (renderPre.val ? pre : span)(text),
)
In this piece of code, the TextDiv
component has a <div>
element whose only child is bound to a boolean
state - renderPre
, which determines whether the <div>
has a <pre>
or <span>
child. Inside the child element, the underlying text is bound to a string
state - text
. Whenever the value of renderPre
is toggled, a new version of the <div>
element will be generated, and we will add a new binding from text
state to the child text node of the newly created <div>
element.
Without proper garbage collection implemented, text
state will eventually be bound to many text nodes after renderPre
is toggled many times. All the of bindings, except for the most recently added one, are actually obsolete, as they bind the text
state to a text node that is not currently being used. i.e.: disconnected from the document tree. Meanwhile, because internally, a State
object holds the reference to all DOM elements are bound to it, these DOM elements won't be GC-ed by JavaScript runtime, causing memory leaks.
Garbage collection is implemented in VanJS to resolve the issue. There are 2 ways a garbage collection activity can be triggered:
- Periodic recycling: periodically, VanJS will scan all
State
objects that have new bindings added recently, and remove all bindings to disconnected DOM elements. i.e.:isConnected
property isfalse
. - Pre-rendering recycling: before VanJS re-render the DOM tree in response to state changes, it will first check all the states whose values have been changed in this render cycle, and remove all bindings to disconnected DOM elements.
Try out the example here (You can use developer console to watch text
's UI _bindings
).
Avoid your bindings to be GC-ed unexpectedly
There are some general guidelines to follow to avoid your bindings being garbage collected unexpectedly:
- Please complete the construction of the DOM tree and connect the newly constructed DOM tree to the
document
object before making any state changes. Otherwise, the bindings to yet-to-be-connected DOM elements will be garbage collected. - DOM tree construction needs to be synchronous. i.e.: you shouldn't have any suspension point while building the DOM tree (e.g.:
await
something in anasync function
). Otherwise, periodic recycling might be scheduled in the middle of the suspension point which can cause bindings to yet-to-be-connected DOM elements being garbage collected.
Derived states and side effects registered inside a binding function
For derived states and side effects registered via van.derive
, if they are registered inside a binding function, they will be garbage collected if the DOM node returned by the binding function becomes disconnected from the document tree. For instance, for the code below:
const renderPre = van.state(false)
const prefix = van.state("Prefix")
const TextDiv = () => div(() => {
const suffix = van.state("Suffix")
const text = van.derive(() => `${prefix.val} - ${suffix.val}`)
return (renderPre.val ? pre : span)(text)
})
whenever renderPre
is toggled, a new text
state will be created and subscribe to changes of the prefix
state. However, the derivation from prefix
to the previous text
state will be garbage collected as the derivation was created while executing a binding function whose result DOM node no longer connects to the document tree. This is the mechanism to avoid memory leaks caused by state derivations that hold onto memory indefinitely.
Try out the example here (You can use developer console to watch prefix
's _listeners
).
Lifecycle Hooks
To keep VanJS's simplicity, there isn't a direct support of lifecycle hooks in VanJS API. That being said, there are multiple ways to inject custom code upon lifecycle events (mount/unmount) of DOM elements.
Using setTimeout
A quick and dirty way to inject custom code upon a DOM element is mounted is to use setTimeout
with a small delay
. Since the rendering cycle starts right after the current thread of execution (internally, the rendering cycle is rescheduled via setTimeout
with a 0
delay
), the custom code injected via setTimeout
with a small delay
is guaranteed to be executed right after the upcoming rendering cycle, which ensures its execution upon the DOM element being mounted. This simple technique is used in a few places of the official VanJS codebase (in the website and demos), e.g.: 1, 2.
Registering a side effect via van.derive
Requires VanJS 1.5.0 or later.
If you want to get rid of setTimeout
(thus the small delay
introduced by it). You can leverage the technique of registering a side effect via van.derive
, as demonstrated in the code below:
const Label = ({text, onmount}) => {
if (onmount) {
const trigger = van.state(false)
van.derive(() => trigger.val && onmount())
trigger.val = true
}
return div({class: "label"}, text)
}
const App = () => {
const counter = van.state(0)
return div(
div(button({onclick: () => ++counter.val}, "Increment")),
() => Label({
text: counter.val,
onmount: () => document.getElementById("msg").innerText =
"Current label: " + document.querySelector(".label").innerText,
}),
div({id: "msg"}),
)
}
Demo:
This technique works because in VanJS 1.5.0 or later, derived states and side effects caused by state changes are scheduled asynchronously right in the next rendering cycle. Thus the side effects caused by the state changes of the current rendering cycle are guaranteed to be executed right after the completion of the current rendering cycle.
Register connectedCallback
and disconnectedCallback
of custom elements
Another option is to leverage the connectedCallback
and disconnectedCallback
of custom elements in Web Components. This is the only option to reliably inject custom code upon the unmount events of DOM elements. Note that in VanJS's add-on: van_element, you can easily register mount/unmount handlers with the help of the add-on.