VanX: The 1.2kB Official VanJS Extension
VanX is the official extension of VanJS, which provides handy utility functions. VanX makes VanJS more ergonomic for certain use cases and its developer experience closer to other popular UI frameworks. Like VanJS, VanX is also ultra-lightweight, with just 1.2kB in the gzipped minified bundle.
Installation
VanX is published as NPM package vanjs-ext. Run the following command to install the package:
npm install vanjs-ext
Add this line to your script to import the package:
import * as vanX from "vanjs-ext"
You can also import individual utility functions you're going to use:
import { <functions you want to use> } from "vanjs-ext"
Alternatively, you can import VanX from CDN via a <script type="text/javascript">
tag:
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/van-x.nomodule.min.js"></script>
https://cdn.jsdelivr.net/npm/[email protected]/dist/van-x.nomodule.js
can be used for the non-minified version.
Note that: VanJS needs to be imported via a <script type="text/javascript">
tag for VanX to work properly.
To get TypeScript support for <script>
tag integration, download van-x-0.6.1.d.ts
and add the following code at the top of your .ts
file:
import type * as vanXType from "./van-x-0.6.1.d.ts"
declare const vanX: typeof vanXType
vanX.reactive
: Reactive Object to Hold Many Individual States
vanX.reactive
provides an ergonomic way to define a single reactive object where each of its individual fields corresponds to an underlying State
object. For instance:
const obj = vanX.reactive({a: 1, b: 2})
defines a reactive object with the following underlying state fields:
{a: van.state(1), b: van.state(2)}
The reactive objects defined by vanX.reactive
can be deeply nested. For instance:
const obj = vanX.reactive({
a: 1,
b: {
c: 2,
d: 3,
},
})
defines a reactive object with the following underlying state fields:
{
a: van.state(1),
b: van.state({
c: van.state(2),
d: van.state(3),
}),
}
Getting and setting values of the underlying states can be simply done by getting / setting the fields of the reactive object. For instance, obj.b.c
is equivalent to what you would have to write obj.b.val.c.val
had the underlying state object been accessed.
A practical example
Now, let's take a look at a practice example on how vanX.reactive
can help group multiple states into a single reactive object in your application:
const Name = () => {
const data = vanX.reactive({name: {first: "Tao", last: "Xin"}})
return span(
"First name: ",
input({type: "text", value: () => data.name.first,
oninput: e => data.name.first = e.target.value}), " ",
"Last name: ",
input({type: "text", value: () => data.name.last,
oninput: e => data.name.last = e.target.value}), " ",
"Full name: ", () => `${data.name.first} ${data.name.last}`, " ",
button({onclick: () => data.name = {first: "Tao", last: "Xin"}}, "Reset"),
)
}
Demo:
Note that, not only you can set the value of each individual leaf field, you can also set the entire object of the name
field, as what's being done in the onclick
handler of the Reset
button:
button({onclick: () => data.name = {first: "Tao", last: "Xin"}}, "Reset")
⚠️ Caveat: Accessing to any sub-field of the reactive object needs to be wrapped inside a binding function. Otherwise, your app won't be reactive to the sub-field changes.
⚠️ Caveat: DO NOT alias any sub-field of the reactive object into other variables. Doing so will break the dependency detection when the sub-field alias is used in a binding function.
API reference: vanX.reactive
Signature | vanX.reactive(obj) => <the created reactive object> |
Description | Converts the input object obj into a reactive object. |
Parameters |
|
Returns | The created reactive object. |
⚠️ Caveat: The passed-in obj
object shouldn't have any State
fields. Doing so will result in states of other State
objects, which is invalid in VanJS.
Calculated fields
You can specify calculated fields (similar to derived states in VanJS) via vanX.calc
. The example above can be rewritten to the code below:
const Name = () => {
const data = vanX.reactive({name: {first: "Tao", last: "Xin"}})
const derived = vanX.reactive({fullName: vanX.calc(() => `${data.name.first} ${data.name.last}`)})
return span(
"First name: ",
input({type: "text", value: () => data.name.first,
oninput: e => data.name.first = e.target.value}), " ",
"Last name: ",
input({type: "text", value: () => data.name.last,
oninput: e => data.name.last = e.target.value}), " ",
"Full name: ", () => derived.fullName, " ",
button({onclick: () => data.name = {first: "Tao", last: "Xin"}}, "Reset"),
)
}
Demo:
⚠️ Caveat: Avoid self-referencing when specify calculated fields. For instance, the code below:
const data = vanX.reactive({
name: {first: "Tao", last: "Xin"},
fullName: vanX.calc(() => `${data.name.first} ${data.name.last}`),
})
will lead to ReferenceError
as data
variable is not yet defined when the calculation function is being executed. As shown in the example above, it's recommended to define calculated fields in a separate reactive object.
API reference: vanX.calc
Signature | vanX.calc(f) => <the created calculated field> |
Description | Creates a calculated field for a reactive object based on the calculation functionf . |
Parameters |
|
Returns | The created calculated field. |
Get the underlying State
object
Sometimes, it's desirable to get the underlying State
objects for fields in a reactive object. This can be achieved with vanX.stateFields
. The example above can be modified to use the underlying state field instead of the binding function for Full name
:
const Name = () => {
const data = vanX.reactive({name: {first: "Tao", last: "Xin"}})
data.fullName = vanX.calc(() => `${data.name.first} ${data.name.last}`)
return div(
"First name: ",
input({type: "text", value: () => data.name.first,
oninput: e => data.name.first = e.target.value}), " ",
"Last name: ",
input({type: "text", value: () => data.name.last,
oninput: e => data.name.last = e.target.value}), " ",
"Full name: ", vanX.stateFields(data).fullName, " ",
button({onclick: () => data.name = {first: "Tao", last: "Xin"}}, "Reset"),
)
}
Demo:
Note that, stateFields
only gets the underlying state fields for one layer of the reactive object. For instance, to get the state field for First name
, you need to write:
vanX.stateFields(vanX.stateFields(data).name.val).first
API reference: vanX.stateFields
Signature | vanX.stateFields(obj) => <an object for all underlying state fields of obj> |
Description | Given a reactive object obj , returns an object for all the underlying state fields of obj . For instance, if obj is {a: 1, b: 2} , {a: van.state(1), b: van.state(2)} will be returned. |
Parameters |
|
Returns | An object for all the underlying state fields of obj . |
Get the raw field value without registering the dependency
Requires VanX 0.3.0 or later.
Similar to the rawVal
property of VanJS states. You can use vanX.raw
for getting the raw field value without registering the dependency. For instance:
data.s = vanX.calc(() => vanX.raw(data).a + data.b)
will make data.s
updated when data.b
changes, but data.s
won't be updated when data.a
changes. The same effect goes to derived states and side effects registered via van.derive
as well as State
-derived DOM nodes.
Note that, vanX.raw
can access deeply nested fields without registering the dependency (this requires VanX 0.4.0 or later). For instance, you can use vanX.raw(data).a.a
to access the field data.a.a
without registering the dependency.
API reference: vanX.raw
Signature | vanX.raw(obj) => <an object for getting the field values of obj without registering the dependency> |
Description | Given a reactive object obj , returns an object whose field values equal to the field values of obj , but accessing its fields won't register the dependency. |
Parameters |
|
Returns | An object with which you can get the field values of obj without registering the dependency. |
Add reactivity to existing JavaScript classes
It's possible to add reactivity to objects of existing JavaScript classes with the help of vanX.reactive
. For instance, the code below adds the reactivity to a Person
object:
class Person {
constructor(firstName, lastName) { this.firstName = firstName; this.lastName = lastName }
get fullName() { return `${this.firstName} ${this.lastName}` }
}
const Name = () => {
const person = vanX.reactive(new Person("Tao", "Xin"))
return div(
"First name: ",
input({type: "text", value: () => person.firstName,
oninput: e => person.firstName = e.target.value}), " ",
"Last name: ",
input({type: "text", value: () => person.lastName,
oninput: e => person.lastName = e.target.value}), " ",
"Full name: ", () => person.fullName, " ",
button({onclick: () => (person.firstName = "Tao", person.lastName = "Xin")}, "Reset"),
)
}
Demo:
⚠️ Caveat: Once an object is turned reactive with vanX.reactive
, you shouldn't access the original object anymore. Doing so will create the same issue as aliasing.
⚠️ Caveat: There might be issues if you try to add reactivity to a class implemented in native code (not in JavaScript), or a class from a 3rd party library. Example: #156.
vanX.noreactive
: exemption from reactivity conversion
Requires VanX 0.6.0 or later.
Sometimes it's desirable to exempt certain fields from being converted into reactive objects. For instance, for the reactive array below:
const data = vanX.reactive([
vanX.noreactive(new ArrayBuffer(8)),
vanX.noreactive(new ArrayBuffer(16)),
])
we will treat the ArrayBuffer
s in data
as primitive fields instead of further converting them into reactive objects. This feature is essential as the objects of certain native or 3rd party classes can't be correctly converted into reactive objects. ArrayBuffer
is one example as wrapping it around a Proxy
will cause problems.
Below is the whole example that illustrates how vanX.noreactive
helps a reactive array of ArrayBuffer
being used in the application:
const data = vanX.reactive([
vanX.noreactive(new ArrayBuffer(8)),
vanX.noreactive(new ArrayBuffer(16)),
])
const App = () => div(
vanX.list(div, data, v => div(v.val.byteLength)),
div(button({onclick: () => data.push(vanX.noreactive(new ArrayBuffer(24)))}, "Push")),
)
API reference: vanX.noreactive
Signature | vanX.noreactive(obj) => <the object exempted from reactivity conversion> |
Description | Marks an object so that it won't be converted into a reactive object. |
Parameters |
|
Returns | The object exempted from reactivity conversion. |
A comprehensive example
You can refer to this file for a comprehensive demo of all the features regarding to reactive objects discussed above. You can preview the app via CodeSandbox.
vanX.list
: Reactive List that Minimizes Re-rendering on Updates
vanX.list
takes an input reactive object and builds a list of UI elements whose contents are updated whenever any field of the input reactive object changes. The input reactive object can either be an Array
for non-keyed input, or a plain object for keyed input.
Let's first take a look at some simple examples.
Array
for non-keyed input:
const items = vanX.reactive([1, 2, 3])
return vanX.list(ul, items, v => li(v))
Plain object for keyed input:
const items = vanX.reactive({a: 1, b: 2, c: 3})
return vanX.list(ul, items, v => li(v))
In both examples, <ul><li>1</li><li>2</li><li>3</li></ul>
will be returned.
You can add, update, and delete entries in the reactive object items
, and the rendered UI elements are bound to the changes while minimizing the re-rendering of the DOM tree. For instance, if you do the following changes to the Array
example:
++items[0]
delete items[1]
items.push(4)
the rendered UI elements will be updated to <ul><li>2</li><li>3</li><li>4</li></ul>
.
For keyed object, the following changes will produce the same result:
++items.a
delete items.b
items.d = 4
In addition, for Array
-based input items
, you can call shift
, unshift
and splice
as you would normally do to an array. The rendered UI elements are guaranteed to be in sync. For instance, after executing the following code:
const items = vanX.reactive([1, 2, 3])
const dom = vanX.list(ul, items, v => li(v))
items.shift()
items.unshift(4)
items.splice(1, 1, 5)
dom
will become <ul><li>4</li><li>5</li><li>3</li></ul>
.
API Reference: vanX.list
Signature | vanX.list(container, items, itemFunc) => <the root element of the created DOM tree> |
Description | Creates a DOM tree for a list of UI elements based on the input reactive object items . |
Parameters |
|
Returns | The root element of the created DOM tree. |
A simplified TODO App
Now, let's take a look at a practical example: The Fully Reactive TODO App in VanJS by Example page can be re-implemented with the help of vanX.list
. We can see how a 40+ lines of code is simplified to just over 10 lines:
const TodoList = () => {
const items = vanX.reactive(JSON.parse(localStorage.getItem("appState") ?? "[]"))
van.derive(() => localStorage.setItem("appState", JSON.stringify(vanX.compact(items))))
const inputDom = input({type: "text"})
return div(
inputDom, button({onclick: () => items.push({text: inputDom.value, done: false})}, "Add"),
vanX.list(div, items, ({val: v}, deleter) => div(
input({type: "checkbox", checked: () => v.done, onclick: e => v.done = e.target.checked}),
() => (v.done ? del : span)(v.text),
a({onclick: deleter}, "❌"),
)),
)
}
Demo:
You might notice how easy it is to serialize/deserialize a complex reactive object into/from external storage. This is indeed one notable benefit of reactive objects provided by vanX.reactive
.
Holes in the array
Deleting items in the reactive array will create holes inside the array, which is an uncommon situation in JavaScript. Basically, if we execute the following code:
const a = [1, 2, 3]
delete a[1]
a
will become [1, empty, 3]
. Note that, empty
is different from undefined
. When we do:
for (const key in a)
or use higher-order functions like map
or filter
, holes will be skipped in the enumeration.
Why do we allow holes in the array? Short answer: to minimize the re-rendering of DOM elements. Let's say if we have a reactive array: [1, 2, 3, 4, 5]
, and the 3rd item is deleted by the user. If we allow holes, the array will become [1, 2, empty, 4, 5]
. Based on how DOM elements are bound to the reactive array, only the 3rd element needs to be removed. However, if we don't allow holes, the array will become [1, 2, 4, 5]
, then we need 3 DOM updates:
- 3rd DOM element:
3
->4
- 4th DOM element:
4
->5
- Remove the 5th DOM element.
In the TODO app above, we are calling vanX.compact
which recursively removes holes in all arrays of the input reactive object before serializing items
to the JSON string via JSON.stringify
. This is because holes are turned into null
values in the result JSON string and cause problems when the JSON string is deserialized (See a detailed explanation here).
⚠️ Caveat: Because of holes in the reactive array, the length
property can't reliable tell the number of items in the array. You can use Object.keys(items).length
instead as in the example below.
vanX.replace
: Update, Insert, Delete and Reorder Items in Batch
In addition to updating the items
object one item at a time, we also provide the vanX.replace
function that allows you to update, insert, delete and reorder items in batch. The vanX.replace
function takes a reactive object - obj
, and a replacement object (or a replacement function) - replacement
, as its input parameters. vanX.replace
is responsible for updating the obj
object as well as UI elements bound to it based on the new data provided by replacement
. Let's take a look at a few examples:
// Assume we have a few TODO items as following:
const todoItems = vanX.reactive([
{text: "Implement VanX", done: true},
{text: "Test VanX", done: false},
{text: "Write a tutorial for VanX", done: false},
])
// Directly specify the replacement object
const refreshItems = () => vanX.replace(todoItems, [
{text: "Publishing VanX", done: true},
{text: "Refining VanX", done: false},
{text: "Releasing a new version of VanX", done: false},
])
// To delete items in batch
const clearCompleted = () => vanX.replace(todoItems, l => l.filter(v => !v.done))
// To update items in batch
const appendText = () =>
vanX.replace(todoItems, l => l.map(v => ({text: v.text + "!", done: v.done})))
// To reorder items in batch
const sortItems = () =>
vanX.replace(todoItems, l => l.toSorted((a, b) => a.localeCompare(b)))
// To insert items in batch
const duplicateItems = () => vanX.replace(todoItems,
l => l.flatMap(v => [v, {text: v.text + " copy", done: v.done}]))
API reference: vanX.replace
Signature | vanX.replace(obj, replacement) => obj |
Description | Updates the reactive object obj and UI elements bound to it based on the data provided by replacement . |
Parameters |
|
Returns | obj |
⚠️ Caveat: Calculated fields are not allowed in obj
and replacement
.
Example 1: sortable list
Let's look at a sample app that we can build with vanX.list
and vanX.replace
- a list that you can add/delete items, sort items in ascending or descending order, and append a string to all items in the list:
const List = () => {
const items = vanX.reactive([])
const inputDom = input({type: "text"})
return div(
div(inputDom, button({onclick: () => items.push(inputDom.value)}, "Add")),
div(() => Object.keys(items).length, " item(s) in total"),
vanX.list(ul, items, (v, deleter) => li(v, " ", a({onclick: deleter}, "❌"))),
div(
button({onclick: () => vanX.replace(items, l => l.toSorted())}, "A -> Z"),
button({onclick: () => vanX.replace(items,
l => l.toSorted((a, b) => b.localeCompare(a)))}, "Z -> A"),
button({onclick: () => vanX.replace(items, l => l.map(v => v + "!"))}, 'Append "!"'),
),
)
}
Demo:
Example 2: an advanced sortable TODO list
Now, let's take a look at a more advanced example - a sortable TODO list, which is implemented with keyed data. i.e.: reactive items
is a plain object instead of an array. In additional to the addition, deletion, sorting and appending strings that are implemented in the previous example, you can edit an item, mark an item as complete, clear all completed items and duplicate the entire list. Furthermore, the application state is serialized and persisted into localStorage
thus the state is preserved across page loads.
const TodoList = () => {
const items = vanX.reactive(JSON.parse(localStorage.getItem("items") ?? "{}"))
van.derive(() => localStorage.setItem("items", JSON.stringify(vanX.compact(items))))
const inputDom = input({type: "text"})
let id = Math.max(0, ...Object.keys(items).map(v => Number(v.slice(1))))
return div(
div(inputDom, button(
{onclick: () => items["k" + ++id] = {text: inputDom.value, done: false}}, "Add")),
div(() => Object.keys(items).length, " item(s) in total"),
vanX.list(div, items, ({val: v}, deleter) => div(
input({type: "checkbox", checked: () => v.done,
onclick: e => v.done = e.target.checked}), " ",
input({
type: "text", value: () => v.text,
style: () => v.done ? "text-decoration: line-through;" : "",
oninput: e => v.text = e.target.value,
}), " ",
a({onclick: deleter}, "❌"),
)),
div(
button({onclick: () => vanX.replace(items, l => l.filter(([_, v]) => !v.done))},
"Clear Completed"),
button({onclick: () => vanX.replace(items, l =>
l.toSorted(([_1, a], [_2, b]) => a.text.localeCompare(b.text)))}, "A -> Z"),
button({onclick: () => vanX.replace(items, l =>
l.toSorted(([_1, a], [_2, b]) => b.text.localeCompare(a.text)))}, "Z -> A"),
button({onclick: () => vanX.replace(items, l =>
l.flatMap(([k1, v1]) => [
[k1, v1],
["k" + ++id, {text: v1.text + " - copy", done: v1.done}],
]))},
"Duplicate List"),
button({onclick: () => Object.values(items).forEach(v => v.text += "!")}, 'Append "!"'),
),
)
}
Demo:
vanX.list
for calculated fields
Requires VanX 0.4.0 or later.
vanX.list
can take a calculated field as items
parameter. Whenever the calculated field is updated, vanX.replace
will be called internally to update the reactive list, as well as all UI elements bound to it. Below is an example which leverages this technique to build a filterable list:
const FilteredCountries = () => {
const countries = [
"Argentina", "Bolivia", "Brazil", "Chile", "Colombia", "Ecuador", "Guyana",
"Paraguay", "Peru", "Suriname", "Uruguay", "Venezuela",
]
const data = vanX.reactive({filter: ""})
const derived = vanX.reactive({
filteredCountries: vanX.calc(
() => countries.filter(c => c.toLowerCase().includes(data.filter.toLowerCase()))),
})
return div(
div("Countries in South America. Filter: ",
input({type: "text", value: () => data.filter, oninput: e => data.filter = e.target.value})),
vanX.list(ul, derived.filteredCountries, v => li(v)),
)
}
Global App State and Serialization
Requires VanX 0.4.0 or later.
With VanX, it's possible consolidate the entire app state into a single reactive object, as reactive objects can hold states in arbitrary nested hierarchies. Below is the code for an upgraded version of the TODO App above, which allows the text of the input box together with all TODO items to be persisted in localStorage
:
const TodoListPlus = () => {
const appState = vanX.reactive(JSON.parse(
localStorage.getItem("appStatePlus") ?? '{"input":"","items":[]}'))
van.derive(() => localStorage.setItem("appStatePlus", JSON.stringify(vanX.compact(appState))))
return div(
input({type: "text", value: () => appState.input, oninput: e => appState.input = e.target.value}),
button({onclick: () => appState.items.push({text: appState.input, done: false})}, "Add"),
vanX.list(div, appState.items, ({val: v}, deleter) => div(
input({type: "checkbox", checked: () => v.done, onclick: e => v.done = e.target.checked}),
() => (v.done ? del : span)(v.text),
a({onclick: deleter}, "❌"),
)),
)
}
Demo:
Note that calculated fields are still recommended to be stored separately, to avoid issues like self referencing or calculated fields being replaced.
Smart diff / update in vanX.replace
When vanX.replace
updates the reactive object obj
, it will traverse the entire object tree, do a diff between replacement
and obj
, and only update leaf-level fields with different values. Thus, you can call vanX.replace
to replace the entire app state object, and VanX guarantees at the framework level that the minimum amount updates are applied to the reactive object and thus the DOM tree bound to it.
For instance, if appState
in the example above has the following value:
{
"input": "New Item",
"items": [
{"text": "Item 1", "done": true},
{"text": "Item 2", "done": false}
]
}
Calling
vanX.replace(appState, {
input: "New Item",
items: [
{text: "Item 1", done: true},
{text: "Item 2", done: true},
]
})
will only get the done
field of 2nd element in items
updated. i.e.: it's equivalent to appState.items[1].done = true
.
Because of the smart diff / update mechanism, it's usually more preferable to use vanX.replace
instead of direct assignment to update the object-valued reactive fields. i.e.: prefer:
vanX.replace(data.objField, <new value>)
instead of
data.objField = <new value>
Server-driven UI (SDUI) with VanX
The smart diff / update mechanism in vanX.replace
enables a new spectrum of modern programming paradigms, such as server-driven UI, where the server sends the entire global app state to the client via JSON or other forms. vanX.replace
guarantees only minimum parts of the global app state to be updated, and thus minimum parts of the DOM tree need to be re-rendered.
Below is a sample Chat app which receives the updates of app state completely from server. Note that with vanX.replace
, only necessary DOM elements will be re-rendered upon receiving the server events:
const ChatApp = () => {
const appState = vanX.reactive({friends: [], messages: []})
;(async () => {for await (const state of serverStateUpdates()) vanX.replace(appState, state)})()
return div({class: "container"},
div({class: "friend-list"},
vanX.list(ul, appState.friends, ({val: v}) => li(
span({class: () => ["status-indicator", v.online ? "online" : "offline"].join(" ")}), " ",
() => v.name,
)),
),
vanX.list(div({class: "chat-messages"}), appState.messages, s => div({class: "message"}, s)),
)
}
Note that in the jsfiddle preview link above, we're simulating the server-side state updates. In real-world applications, state updates can be sent from server via server-sent events, WebSocket
messages, or HTTP polling.
Serialization app state and vanX.compact
You can serialize the entire app state into a single string, via JSON.stringify
or protobuf. As mentioned in a previous section, holes that might appear in reactive arrays need to be eliminated. vanX.compact
does exactly that. It traverses the entire object tree of the input reactive object and returns a new object with holes in all encountered arrays eliminated.
API reference: vanX.compact
Signature | vanX.compact(obj) => <a new object with holes in all arrays eliminated> |
Description | Traverse the entire object tree of the input reactive object obj and returns a new object with holes in all encountered arrays eliminated. The input object obj remains unchanged. |
Parameters |
|
Returns | A new object with holes eliminated. |
API Index
Below is the list of all top-level APIs in VanX: