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 {b, button, span} = van.tags
const TurnBold = () => {
const vanJS = van.state("VanJS")
return span(
button({onclick: () => vanJS.val = b("VanJS")}, "Turn Bold"),
" Welcome to ", vanJS, ". ", vanJS, " is awesome!"
)
}
van.add(document.body, TurnBold())
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.
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({value: formula, oninput: e => formula.val = e.target.value},
option("a + b"), option("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({value: formula, oninput: e => formula.val = e.target.value},
option("a + b"), option("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:
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
).