Fundamentals
State and scope with v-data
Every interactive page has to remember something: whether the menu is open, what was typed,
which items are on the list. In Voodoo that lives in a v-data, and the piece of
HTML that declares it owns that state.
The basics
The value of v-data is an object. Each key becomes a variable available on that
element and on everything inside it.
<div v-data="{ open: false, items: ['bread', 'milk'] }">
<button @click="open = !open">Toggle menu</button>
<p v-show="open">{ items.length } items on the list</p>
</div>
There is no declaration in another file, no setState, no registration step. The
object in the attribute is the state.
Scope, and what one scope sees of another
Each v-data creates a scope. Scopes nest, and the inner one sees the outer one.
<div v-data="{ theme: 'dark' }">
<div v-data="{ open: false }">
<button @click="open = !open">Current theme: { theme }</button>
<p v-show="open">The button sees theme and open at the same time.</p>
</div>
</div>
Two rules head off practically every surprise here:
-
A read walks up the chain. Voodoo looks for the variable in the nearest
v-data, then in the ancestors, then at the root. -
A write goes to whoever already has the key. Writing to
themefrom inside the inner scope changes the outer scope'stheme, not a copy. A key that exists nowhere is created in the local scope.
Writing @click="opne = true" with a typo blows up nothing: the library creates the
key opne in the local scope, and the v-show="open" stays put, with no
message at all. If a value does not react, start by checking the name. Turning on
data-devtools in the script tag turns these cases into a console warning.
What is reactive
Everything inside a v-data becomes an observed object, all the way down. Changing a
value, pushing an item onto a list, deleting a key: any of those re-runs only the pieces of the
page that read that value.
<div v-data="{ cart: [] }">
<button @click="cart.push({ name: 'Mug', price: 39 })">Add</button>
<button @click="cart.pop()">Remove</button>
<p>{ cart.length } items, total { cart.reduce((s, i) => s + i.price, 0) }</p>
</div>
The paragraph updates because it read cart.length. Another paragraph that reads
nothing from cart is never even visited — that is what the documentation means by
granular updates.
Every array method works
push, pop, splice, sort,
reverse, shift and unshift all trigger the update.
Assigning by index (list[2] = 'x') and touching list.length do too.
The classic limitation of having to replace the whole array does not exist here.
Where the functions live
The value of v-data is an expression, not a block of JavaScript: it takes objects,
lists, numbers and strings, but it does not take methods written inside it. This does
not work:
<!-- does not work: the expression parser does not accept a function body -->
<div v-data="{ items: [], total() { return this.items.length } }">
Logic longer than one line lives in one of the three places below, and the HTML calls it by name:
<div>
<script>
V.data({
prices: { Mug: 39, Shirt: 89 },
total(items) {
return items.reduce((sum, name) => sum + this.prices[name], 0);
}
});
</script>
<div v-data="{ items: ['Mug'] }">
<button @click="items.push('Shirt')">Add a shirt</button>
<p>{ items.length } items, $ { total(items) }</p>
</div>
</div>
| The function is used | Write it in |
|---|---|
| By a whole page | V.data({ ... }), the root scope |
| Alongside shared state | A store, where this is the store itself |
| Only by one reusable piece | The methods of a component |
Short expressions still fit in the attribute — @click="items.push('x')",
{ items.filter(i => i.paid).length }. The limit is the function body, not the
length.
Running something the moment the scope is born
v-init runs an expression exactly once, right after the scope is created. It is
there to prepare derived values, fire a first fetch or read something off the document.
<div>
<script>
V.config.globals.timeNow = () => new Date().toLocaleTimeString();
</script>
<div v-data="{ now: '' }" v-init="now = timeNow()">
<p>This box opened at { now }.</p>
</div>
</div>
The example goes through V.config.globals because an attribute expression does not
accept new: it is a subset of JavaScript, and constructors are left out. Anything
that needs new, function or more than one line lives in a script and
enters the expression by name.
Where to keep each thing
| The data is | Keep it in |
|---|---|
| Used only by that block of HTML | v-data on the element that wraps the block |
| Used by distant parts of the page | A global store, with $store |
| Required to survive an F5 | v-persist, on the persistent state page |
| Coming from a server | HTTP directives, which already write into the scope |
| The same thing repeated in several places | A component, with the state inside it |
Reading and writing the scope from JavaScript
Sometimes the value has to change from loose code — a callback from another library, a
setInterval, an event from the browser itself. V.getScope(element)
returns that node's scope, and .data is the reactive object: writing to it updates
the screen exactly the way a directive does.
<div id="panel" v-data="{ online: true }">
<p>{ online ? 'Connected' : 'No connection' }</p>
</div>
<script>
const panel = V.getScope(document.getElementById('panel')).data;
addEventListener('offline', () => { panel.online = false; });
addEventListener('online', () => { panel.online = true; });
</script>
V.getScope(node) returns the scope declared on that node, or undefined
if it declared none. V.findScope(node) walks up the ancestors until it finds one,
and falls back to the root scope when it does not. The root scope is V.scope, and
V.data({ ... }) writes values into it that the whole page can see.
For this specific case, the v-online and v-offline directives already
do the work without JavaScript. The example stands as a way of reaching the scope from the
outside, which is what matters here.