Fundamentals
JSX in plain HTML
Write { list.map(x => (<li>{ x }</li>)) } straight into the markup of an
ordinary .html file. No build step, no bundler, no compiler, no JSX transform. Save
the file, open it in a browser, and it runs.
The first one
Everything below is a real page. Load the full build, write the expression where you want the result, and that is the whole setup.
<div v-data="{ fruits: ['apple', 'pear', 'grape'] }">
<ul>
{fruits.map((fruit) => (
<li>{fruit}</li>
))}
</ul>
</div>
It has to be voodoo.full.min.js. JSX is not in the core or essential builds, and
those two say so in the console rather than failing quietly.
How it can possibly work
There is no compiler here, and no eval. The trick is that the browser has already
parsed the page before any script runs, and what it leaves behind is recoverable.
For the list above, the DOM is three siblings:
text "{fruits.map((fruit) => ("
element <li>{fruit}</li>
text "))}"
The element is not damage to route around. It is the template. Rejoining the text with a placeholder where the element sat reconstructs the expression exactly as it was typed:
fruits.map((fruit) => ($t(0)))
$t(0) hands back a reference to that <li>, together with the scope
the call was made in, so the arrow returns one clone per item and each renders where
fruit is bound. From there it goes through the same lexer, parser and interpreter as
every other expression in the library. Nothing is compiled, nothing is evaluated as a string, and
a strict Content Security Policy is unaffected.
Conditionals that return elements
A ternary and && behave the way they do in JSX, down to rendering nothing for
true, false, null and undefined.
<div v-data="{ loggedIn: true, level: 2 }">
<p>{loggedIn ? <b>Welcome back</b> : <b>Please sign in</b>}</p>
<p>{loggedIn && <b>Shown only when true</b>}</p>
<button @click="loggedIn = !loggedIn">toggle</button>
</div>
And a real if / else if / else, which JSX itself does not offer.
<div v-data="{ score: 72 }">
<input type="range" min="0" max="100" v-model.number="score">
<p>score: {score}</p>
<p>
{if (score >= 90)
(<b>excellent</b>)
else if (score >= 60)
(<b>pass</b>)
else
(<b>fail</b>)}
</p>
</div>
Callbacks with a real body
const, if and an early return all work inside the callback,
so branching per item reads the way you would write it anywhere else.
<div v-data="{ products: [
{ name: 'Laptop', stock: 5 },
{ name: 'Chair', stock: 0 },
{ name: 'Mouse', stock: 12 }
] }">
<ul>
{products.map(item => {
if (item.stock === 0) {
return (<li>{item.name}: out of stock</li>);
}
const label = item.stock > 6 ? 'plenty' : 'a few';
return (<li>{item.name}: {label} ({item.stock})</li>);
})}
</ul>
</div>
Nesting
Templates nest, and an inner one still sees the outer loop variable.
<div v-data="{ groups: [
{ title: 'Fruit', items: ['apple', 'pear'] },
{ title: 'Tools', items: ['hammer', 'saw', 'drill'] }
] }">
{groups.map((group, g) => (
<section>
<h3>{group.title}</h3>
<ul>
{group.items.map((item, i) => (
<li>{g}.{i} {item}</li>
))}
</ul>
</section>
))}
</div>
Tables
Tables are the hardest case, and they work. Loose text is not allowed inside
<table> or <tbody>, so the HTML parser foster parents
it: the text is moved out to just before the table and the elements stay in. The expression and
its template end up in different parents.
Nothing is lost, though, only moved, and moved predictably. The text keeps the empty parentheses
where the element used to be, and the element is in the table alongside, so the two are matched
back up and the rendered rows are anchored in the tbody where they belong.
<div v-data="{ rows: [
{ name: 'Ana', score: 92 },
{ name: 'Bruno', score: 47 },
{ name: 'Caio', score: 78 }
] }">
<table>
<thead>
<tr><th>Name</th><th>Score</th><th>Result</th></tr>
</thead>
<tbody>
{rows.map(r => (
<tr>
<td>{r.name}</td>
<td>{r.score}</td>
<td>{r.score >= 60 ? <b>pass</b> : <b>fail</b>}</td>
</tr>
))}
</tbody>
</table>
</div>
Declaring the data in the page
A { ... } block at the top of the body puts names in scope, with no
v-data anywhere. const, let and var all work.
{
const user = 'Ana';
const tags = ['html', 'css', 'js'];
}
<h3>Hello, {user}!</h3>
<ul>
{tags.map(tag => (
<li>#{tag}</li>
))}
</ul>
It stays live
A region is a reactive effect, not a template rendered once. Mutating the array re-renders the list, and only the nodes that depend on the data are touched.
<div v-data="{ items: [1, 2, 3] }">
<button @click="items.push(items.length + 1)">add</button>
<button @click="items.pop()">remove</button>
<button @click="items.reverse()">reverse</button>
<p>{items.length > 0 ? <b>{items.length} items</b> : <b>empty</b>}</p>
<ul>
{items.map((x, i) => (
<li>{i}: {x}</li>
))}
</ul>
</div>
Two rules that keep it out of your way
A region is only claimed when an element sits inside the braces. { count } on
its own is ordinary interpolation and belongs to the core renderer, and a stray { in
prose never balances, so it is left alone.
And script, style, pre, code,
samp, kbd, textarea, template and
noscript are never scanned. A page full of example code stays a page full of example
code, which is what lets this documentation show JSX without executing it.
What does not work
Three things, listed rather than left to be discovered.
There is no style={{ ... }}. Bind the attribute instead
<div :style="{ backgroundColor: color }"> <!-- this is the way -->
<div style="{{ backgroundColor: color }}"> <!-- does nothing -->
<div style={{ backgroundColor: color }}> <!-- does nothing -->
An earlier version of this page had the middle line marked as working. It does not, and the
correction is worth stating plainly: a plain style attribute is never treated as an
expression. Only v-*, : and @ attributes are read, so
{{ ... }} written into style survives verbatim as a nonsense CSS
declaration and the element renders unstyled. Prefix the attribute with a colon and the ordinary
binding does exactly what was wanted.
The last line fails twice over, and the second reason applies to every attribute. An unquoted
value ends at the first space, so the browser turns it into six separate attributes
(style="{{", backgroundcolor:="" and so on) before any script has run.
The pieces survive in order and could in principle be rejoined, but attribute names are lowercased
on the way in, so backgroundColor comes back as backgroundcolor and any
identifier carrying a capital is gone for good. Quote your attribute values.
Fragments and loop statements
<> does nothing, because the browser creates no element for it and there is
therefore no template to clone. And for, while, do/while,
break and continue are not part of the expression language. Use
map, or Array.from({ length: n }) when you need a range.
<div v-data="{ n: 4 }">
<input type="range" min="1" max="10" v-model.number="n">
{Array.from({ length: n }, (_, i) => (
<span>[{i}] </span>
))}
</div>
Not inside a v-for template
A v-for template is cloned once per row during the walk, and JSX regions are taken
out of the page before it, so each clone would receive an anchor with nothing attached and the
cell would come out empty. Use one or the other on the same subtree, not both:
v-if on the element when a row needs to branch into markup, or a JSX region instead
of v-for entirely.
Why the ordering matters
Worth knowing if you ever call the pieces yourself. JSX happens in two passes around
V.start(), and both sides are load-bearing.
The templates leave the document before the walk. An element written inside a region is an ordinary element until then, so the walk would try to render it, fail on the callback parameter it mentions, and rewrite its text in place, which poisons every clone taken from it afterwards.
The effects are created after the walk, because v-data builds its scopes
during it and a region inside one has to resolve against that scope rather than the page's.
V.jsx() does both in one call, which is what you want when driving it by hand.
V.extractJsx() and V.activateJsx() are the halves, for when you need
them on opposite sides of your own start.