Advanced Topics

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"}),
  )
)

Try on jsfiddle

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:

Try on jsfiddle

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/).
})()

Try on jsfiddle

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:

Try on jsfiddle

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:

Try on jsfiddle

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:

  1. 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 is false.
  2. 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:

  1. 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.
  2. 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 an async 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).