VanJS: Tutorial and API Reference
In this tutorial, we will break down into 3 core functionalities VanJS supports: DOM composition / manipulation, State and State binding.
DOM Composition and Manipulation
Your first VanJS app: a simple Hello
page
We will start this tutorial with a simple Hello
page, with the code below:
const {a, div, li, p, ul} = van.tags
const Hello = () => div(
p("👋Hello"),
ul(
li("🗺️World"),
li(a({href: "https://vanjs.org/"}, "🍦VanJS")),
),
)
van.add(document.body, Hello())
The code should be self-explanatory if you have some familiarity with HTML. Unlike React, everything in the code above is just pure JavaScript, meaning that you are simply calling functions from van.js
without any transpiling that converts your source code into another form. Reusable UI components built with VanJS can be pure vanilla JavaScript functions as well. Here we capitalize the first letter to follow React conventions.
Also unlike React, VanJS does not introduce an ad-hoc virtual DOM layer. All the tag functions above directly return the created DOM objects. e.g.: the function call p("👋Hello")
simply creates an HTMLParagraphElement
with 👋Hello
as its innerText
, meaning that you can directly interact with your created DOM nodes with native DOM APIs.
API reference: van.tags
van.tags
is a top-level dynamic object in VanJS implemented with Proxy
. van.tags.<name>
gets you a function that creates an HTML element with tag name <name>
. A common way of using van.tags
is like the line below:
const {a, div, p} = van.tags
With the line, a
, div
, p
are functions that create <a>
, <div>
, <p>
HTML elements respectively.
We will use div
function as an example, the API reference for div
tag function is as below:
Signature | div([props], ...children) => <the created DOM element> |
Description | Creates an HTMLDivElement with props as its properties and children as its child nodes. |
Parameters |
|
Returns | The HTMLDivElement object just created. |
SVG and MathML Support
HTML elements with namespace URI
can be created via van.tagsNS
, a variant of van.tags
that allows you to specify the namespaceURI
of the created elements. Here is an example of composing the SVG DOM tree with van.tagsNS
:
const {circle, path, svg} = van.tagsNS("http://www.w3.org/2000/svg")
const Smiley = () => svg({width: "16px", viewBox: "0 0 50 50"},
circle({cx: "25", cy: "25", "r": "20", stroke: "black", "stroke-width": "2", fill: "yellow"}),
circle({cx: "16", cy: "20", "r": "2", stroke: "black", "stroke-width": "2", fill: "black"}),
circle({cx: "34", cy: "20", "r": "2", stroke: "black", "stroke-width": "2", fill: "black"}),
path({"d": "M 15 30 Q 25 40, 35 30", stroke: "black", "stroke-width": "2", fill: "transparent"}),
)
van.add(document.body, Smiley())
Demo:
Similarly, math formulas can be created with MathML
elements via van.tagsNS
:
const {math, mi, mn, mo, mrow, msup} = van.tagsNS("http://www.w3.org/1998/Math/MathML")
const Euler = () => math(
msup(mi("e"), mrow(mi("i"), mi("π"))), mo("+"), mn("1"), mo("="), mn("0"),
)
van.add(document.body, Euler())
Demo:
API reference: van.tagsNS
Signature | van.tagsNS(namespaceURI) => <the created tags object for elements with specified namespaceURI> |
Description | Creates a tags Proxy object similar to van.tags for elements with specified namespaceURI . |
Parameters |
|
Returns | The created tags object. |
API reference: van.add
van.add
function is similar to tag functions described above. Instead of creating a new HTML element with specified properties and children, van.add
function mutates its first argument (which is an existing Element node
) by appending 0 or more children with appendChild
calls:
Signature | van.add(dom, ...children) => dom |
Description | Mutates dom by appending 0 or more child nodes to it. Returns dom for possibly further chaining. |
Parameters |
|
Returns | dom |
DOM nodes already in the document tree can't be used as children
As mentioned in the API reference, if a DOM node is already connected to the document tree, it shouldn't be used as the child node of tag function or van.add
. The following code is invalid and an Error
will be thrown when van-<version>.debug.js
is being used:
const existing = document.getElementById("some-id")
// Invalid! Existing node can't be used as the child node of tag function.
const dom = div({id: "new-id"}, existing)
// Invalid! Existing node can't be appended to other nodes in `van.add`.
van.add(document.body, existing)
Functional-style DOM tree building
Because both tag functions and van.add
can take Array
arguments and the Array
arguments can be deeply nested. VanJS enables very ergonomic DOM tree composition in functional-style. See examples below:
Building a bullet list:
const {li, ul} = van.tags
const List = ({items}) => ul(items.map(it => li(it)))
van.add(document.body, List({items: ["Item 1", "Item 2", "Item 3"]}))
Building a table:
const {table, tbody, thead, td, th, tr} = van.tags
const Table = ({head, data}) => table(
head ? thead(tr(head.map(h => th(h)))) : [],
tbody(data.map(row => tr(
row.map(col => td(col)),
))),
)
van.add(document.body, Table({
head: ["ID", "Name", "Country"],
data: [
[1, "John Doe", "US"],
[2, "Jane Smith", "CA"],
[3, "Bob Johnson", "AU"],
],
}))
on...
event handlers
In tag functions, you can provide a function
value for property keys like on...
. This is a convenient way to specify event handlers. For instance, the code below creates a button
that shows an alert whenever clicked:
button({onclick: () => alert("Hello from 🍦VanJS")}, "Hello")
🎉 Congratulations! You have mastered the skills for building and manipulating DOM trees using VanJS's declarative API, which is incredibly powerful for creating comprehensive applications with elegant code. In the sections below, you will continue to learn how to build reactive applications with state and state binding.
If your application doesn't rely on state and state binding, you can use the slimmed-down version of VanJS - Mini-Van.
State
A State
object in VanJS represents a value that can be updated throughout your application. A State
object has a public property val
, with a custom setter that automatically propagates changes to DOM nodes that are bound to it. In addition, you can register your event handler to listen to updates of a State
object via its onnew
method.
The code below illustrates how a State
object can be used:
const {button, div, input, sup} = van.tags
// Create a new State object with init value 1
const counter = van.state(1)
// Log whenever the value of the state is updated
counter.onnew((v, oldV) => console.log(`Counter: ${oldV} -> ${v}`))
// Used as a child node
const dom1 = div(counter)
// Used as a property
const dom2 = input({type: "number", value: counter, disabled: true})
// Used in a state-derived property
const dom3 = div(
{style: {deps: [counter], f: c => `font-size: ${c}em;`}},
"Text")
// Used in a complex binding
const dom4 = van.bind(counter, c => div(c, sup(2), ` = ${c * c}`))
// Button to increment the value of the state
const incrementBtn = button({onclick: () => ++counter.val}, "Increment")
const resetBtn = button({onclick: () => counter.val = 1}, "Reset")
van.add(document.body, incrementBtn, resetBtn, dom1, dom2, dom3, dom4)
Demo:
API reference: van.state
Signature | van.state(initVal) => <the created State object> |
Description | Creates a State object with its init value specified in the argument. |
Parameters |
|
Returns | The created State object. |
Public interface of State
objects
- Property
val
- the current value of theState
object. When a new value of this property is set, all event handlers registered viaonnew
method will be called and all DOM nodes that bound to it will be updated accordingly. Note that: while settingval
, if the provided value is the same as the current one, event handlers and DOM updates will be skipped. - Method
onnew(l)
- register an event handler to listen to the updates of theState
object. Whenever a new value is assigned to the state, the event handlerl
will be called with 2 arguments:v
andoldV
, representing the new and old value of the state.
The value of a State
object can be almost anything, primitive, Object
, Array
, null
, etc., with 2 ad-hoc exceptions that we made: The value of the State
object cannot be a DOM node, or another State
object. Having values in these 2 types carries little semantic information and is more likely a result of coding bugs. Thus we disallow State
objects to have values in these 2 types. In van-{version}.debug.js
, an explicit error will be thrown if you try to assign a DOM node or another State
object as the value of a state.
State.val
is immutable
While you can update State
objects by setting the val
property, you should never mutate the underlying object of val
itself. Doing so will not trigger the DOM tree update as you would expect and might result in undefined behavior due to aliasing. In van-<version>.debug.js
, attempt to mutate the object in val
will lead to an Error
to be thrown.
State Binding
Once State
objects are created, we can bind them to DOM nodes in various ways to make your UI reactive to state changes.
State
objects as child nodes
State
objects can be used as child nodes in tag functions
and van.add
, like the Counter
example shown in the home page. For a State
object used as a child node, its value needs to be primitive (string
, number
, boolean
or bigint
), and a Text node
will be created for it. The content of the created Text node
will be always in sync with the value of the state.
The following code shows how to build a simple timer with this feature:
const {button, span} = van.tags
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const Timer = ({totalSecs}) => {
const secs = van.state(totalSecs);
return span(
secs, "s ",
button({onclick: async () => {
while (secs.val > 0) await sleep(1000), --secs.val
await sleep(10) // Wait briefly for DOM update
alert("⏰: Time is up")
secs.val = totalSecs
}}, "Start"),
)
}
van.add(document.body, Timer({totalSecs: 5}))
Demo:
State
objects as properties
State
objects can be used as properties of HTML elements. Similar to State
-based child nodes, the value of the properties will be always in sync with the value of the respective states. When State
objects are used as properties, you need to make sure that the values of the states are always valid property values, i.e.: primitives or function
s (for event handlers).
The following code demonstrates 2 text inputs
whose values are always in sync:
const {input, span} = van.tags
const ConnectedProps = () => {
const text = van.state("")
return span(
input({type: "text", value: text, oninput: e => text.val = e.target.value}),
input({type: "text", value: text, oninput: e => text.val = e.target.value}),
)
}
van.add(document.body, ConnectedProps())
Demo:
State
-derived properties
State
-derived property is a more advanced way to bind a property of an HTML element to one or more underlying State
objects. To use State
-derived properties, you need to provide an object with the following fields as the value in props
argument while calling to a tag function
:
deps
- anArray
of one or more dependencies.f
- afunction
that takes the values of states indeps
as parameters. The return value off
should always be valid property values, i.e.: primitives orfunction
s (for event handlers).
The example below is a live font size and color preview implemented with this feature:
const {input, option, select, span} = van.tags
const FontPreview = () => {
const size = van.state(16), color = van.state("black")
return span(
"Size: ",
input({type: "range", min: 10, max: 36, value: size,
oninput: e => size.val = e.target.value}),
" Color: ",
select({oninput: e => color.val = e.target.value, value: color},
["black", "blue", "green", "red", "brown"]
.map(c => option({value: c}, c)),
),
span({style: {deps: [size, color], f: (size, color) =>
`font-size: ${size}px; color: ${color};`}}, " Hello 🍦VanJS"),
)
}
van.add(document.body, FontPreview())
Demo:
Complex State
binding
You can call van.bind
to bind an HTML node to one or more State
objects in a custom way, as specified in a generation function that you provide. The following example illustrates this:
const {input, li, option, select, span, ul} = van.tags
const SortedList = () => {
const items = van.state("a,b,c"), sortedBy = van.state("Ascending")
return span(
"Comma-separated list: ",
input({oninput: e => items.val = e.target.value,
type: "text", value: items}), " ",
select({oninput: e => sortedBy.val = e.target.value, value: sortedBy},
option({value: "Ascending"}, "Ascending"),
option({value: "Descending"}, "Descending"),
),
van.bind(items, sortedBy, (items, sortedBy) =>
sortedBy === "Ascending" ?
ul(items.split(",").sort().map(i => li(i))) :
ul(items.split(",").sort().reverse().map(i => li(i)))),
)
}
van.add(document.body, SortedList())
Demo:
API reference: van.bind
Signature | van.bind(dep1, dep2, ..., depN, f) => <the created DOM node> |
Description | Creates a DOM node that binds to dependencies: dep1 , dep2 , ..., depN . Whenever the values of these states change, the generation function - f , will be called to update the DOM tree. |
Parameters |
|
Returns | The created DOM node that are bound to dependencies. |
Polymorphism between State
and non-State
dependencies
State-derived properties and van.bind
can accept both State
and non-State
objects as dependency arguments. This polymorphism makes it handy to build reusable components where users can specify both state and non-state property values. Non-state dependencies behave the same way as state dependencies whose val
properties never change. Below is an example of a reuseable button whose color
, text
and onclick
properties can be both state and non-state objects:
const {button, span} = van.tags
const Button = ({color, text, onclick}) => button({
style: {deps: [color], f: color => `background-color: ${color};`},
onclick,
}, text)
const App = () => {
const colorState = van.state("green")
const textState = van.state("Turn Red")
const turnRed = () => {
colorState.val = "red"
textState.val = "Turn Green"
onclickState.val = turnGreen
}
const turnGreen = () => {
colorState.val = "green"
textState.val = "Turn Red"
onclickState.val = turnRed
}
const onclickState = van.state(turnRed)
return span(
Button({color: "yellow", text: "Click Me", onclick: () => alert("Clicked")}), " ",
Button({color: colorState, text: textState, onclick: onclickState}),
)
}
van.add(document.body, App())
Demo:
Removing a DOM node
As noted in the API reference above, when generation function f
returns null
or undefined
, the DOM node will removed. Removed DOM node will never be brought back, even when f
would return a non-null
/undefined
value based on future values of the dependencies.
The following code illustrates how to build an editable list with this features:
const {a, button, div, input, li, ul} = van.tags
const ListItem = ({text}) => {
const deleted = van.state(false)
return van.bind(deleted, d => d ? null : li(
text, a({onclick: () => deleted.val = true}, "❌"),
))
}
const EditableList = () => {
const listDom = ul()
const textDom = input({type: "text"})
return div(
textDom, " ", button({
onclick: () => van.add(listDom, ListItem({text: textDom.value})),
}, "➕"),
listDom,
)
}
van.add(document.body, EditableList())
Demo:
Stateful binding
While dealing with state updates, a user can choose to, instead of regenerating the new version of the DOM node entirely based on new state values, mutate the existing DOM node that is already connected to the document tree based on all the new values and old values of its dependencies. This feature can be used as an optimization to avoid the entire DOM subtree being completely re-rendered.
The following code is a snippet of the auto-complete application which leverages this feature to optimize:
const suggestionList = van.bind(candidates, selectedIndex,
(candidates, selectedIndex, dom, oldCandidates, oldSelectedIndex) => {
if (dom && candidates === oldCandidates) {
// If candidate list doesn't change, we don't need to re-render the
// suggestion list. Just need to change the selected candidate.
dom.querySelector(`[data-index="${oldSelectedIndex}"]`)
?.classList?.remove("selected")
dom.querySelector(`[data-index="${selectedIndex}"]`)
?.classList?.add("selected")
return dom
}
return SuggestionList({candidates, selectedIndex})
})
The piece of code above is building a suggestionList
that is reactive to the changes of selection candidates
and selectedIndex
. When selection candidates
change, the suggestionList
needs to be regenerated. However, if only selectedIndex
changes, we only need to update the DOM element to indicate that the new candidate is being selected now, which can be achieved by changing the classList
of relevant candidate elements.
The generation function f
can either return dom
(the existing node in the document tree), or a newly created DOM node. When a newly created DOM node is returned, it shouldn't be already connected to a document tree (isConnected
property should be false
)
Note that, when the generation function is being called for the first time, dom
and oldV1
, oldV2
, ...,oldVN
will all be undefined
. Thus the generation function of stateful binding needs to handle this situation as well. This is why in Line 3, we're checking dom
in the if
statement.
The End
🎉 Congratulations! You have completed the entire tutorial of VanJS. Now you can start your journey of building feature-rich applications!
To learn more, you can:
- check out a list of sample applications built with VanJS.
- read the in-depth discussion of a few advanced topics.
API Index
Below is the list of all top-level APIs in VanJS: