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.