{{ product.title }}: One Expression, Rendered and Re-rendered Forever
A product table renders its first row correctly, but a colleague's copy shows literal {{ product.price }} on screen. That failure — an uncompiled mustache — always has one of two causes: the mustache lives in index.html or a public/ file that Vite never compiles (only <template> blocks in .vue files pass through the Vue compiler), or it sits inside a native HTML file served as the app's entry. The mustache syntax is not "special text that Vue finds later"; it is a compile-time construct. If the compiler never sees it, no runtime magic will.
When it does see it, {{ value }} becomes a text binding: the compiler emits a render function that writes String(expression) into a text node, and the reactive system re-runs that render whenever any ref or property the expression read changes. Writing it as an expression — not a fragment of a program — is the crucial constraint:
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const user = ref<{ name?: string }>({})
</script>
<template>
<!-- legal: expressions -->
<p>Count: {{ count + 1 }}</p>
<p>Name: {{ user.name ?? 'anonymous' }}</p>
<p>Verdict: {{ count > 3 ? 'many' : 'few' }}</p>
<p>Safe access: {{ user.address?.city ?? '—' }}</p>
<!-- illegal: statements never compile -->
<!-- {{ const doubled = count * 2 }} -->
<!-- {{ if (count > 0) { return 'yes' } }} -->
</template>
Statements fail because there is nothing to evaluate into a single value: the compiler parses the mustache content as an expression and reports an Error compiling template diagnostic during vite dev or build. Assignments ({{ count = 5 }}), variable declarations, if/for/while, and multiple statements separated by ; are all rejected. The intended shape is computation, not control flow — logic belongs in script (computed properties, methods, helpers) and structure belongs in directives you will meet next (v-if, v-for).
Expressions can do real work, including calling functions and constructing objects:
<span>{{ formatPrice(price) }}</span>
<span>{{ new Date(orderedAt).toLocaleDateString() }}</span>
<span>{{ items.map(i => i.name).join(', ') }}</span>
Two cautions attach to that freedom. First, the expression re-evaluates on every re-render of its component, so a function with side effects (analytics, console logging) fires repeatedly — templates must be pure reads. Second, templates execute with a whitelist of globals — Math, Date, JSON, Number, Array, Object, String, Boolean, RegExp, console and a few standard parse/encode helpers are available, but arbitrary window globals are not, and anything application-specific must arrive through props, provides, or imports exposed by script setup.
That whitelist exists because a template is a sandboxed view over your state: it cannot assign to anything it reads, which is why the reactive system can track it. Every dependency the expression touches (the count ref, the user.name property) is recorded during rendering; a later write to exactly that dependency schedules a re-render. When a value does not update after you change it, the devtools loop from Vue Devtools in the Browser applies: select the component, watch the binding, and check whether the write actually reached the ref the expression reads. In Common Reactivity Pitfalls you will see the cases where that link silently breaks.
What Renders for null, 0, false, and [object Object]: Reading Coercion Exactly
Interpolation always produces text, and the rule behind what you see is one line: null and undefined render as the empty string; everything else goes through String(value). Memorizing the table that follows prevents a whole class of "why is that blank" and "why does 0 not show" bug reports.
<template>
<!-- state: count=0, ok=false, name=null, data={}, list=[] -->
<p>[{{ name }}]</p> <!-- [] -> null renders '' -->
<p>[{{ count }}]</p> <!-- [0] -> 0 is real text -->
<p>[{{ ok }}]</p> <!-- [false] -> the word false -->
<p>[{{ data }}]</p> <!-- [[object Object]] -->
<p>[{{ list }}]</p> <!-- [] -> String([]) is '' -->
<p>[{{ [1, 2] }}]</p> <!-- [1,2] -> Array#join via String() -->
</template>
Walk through the surprises one by one:
0andfalseare not blank. They coerce to the strings"0"and"false". This asymmetry withv-if(which treats both as falsy and removes elements entirely) is one of the most common sources of confusion in the first weeks of Vue — you will pick it up in the next topic. Interpolation has no falsiness; it renders the value.nullandundefinedare blank, so an optional field does not print "undefined" anywhere — a small mercy over raw JavaScript string concatenation, where"Total: " + pricewith a missing price yields"Total: undefined".- Empty containers print empty.
String([])is""andString([1, 2])is"1,2"(the array join rule).String({})is"[object Object]", which is almost never what a user should see — if your template prints[object Object], the fix is not better coercion, it is rendering a specific property ({{ product.sku }}) or formatting in script (JSON.stringifyfor debugging output). - Dates print their
toString(), e.g.Sat Oct 11 2026 ...— long, locale-hostile, and slow to compare by eye. Format in script:new Date(orderedAt).toLocaleDateString()in the template works, but a sharedformatDatehelper keeps three tables consistent instead of three inline expressions. - TypeScript helps before runtime. With the script-setup TS setup,
{{ user.name.length }}on an optionalnameis a compile error undervue-tsc, catching the crash that plain interpolation would have hit at render time — another argument for accepting the TypeScript prompt during scaffolding.
Coercion also explains a rendering detail people notice in devtools: interpolation compiles into one text node per mustache, so <p>Hi {{ name }}!</p> produces three text children (Hi , the dynamic value, !) and only the middle one is ever patched. The static parts are hoisted out of the render function and never touched again — the first concrete evidence that Vue's compiler does the performance work, not your template code.
Whitespace behaves predictably but is worth knowing: newlines and indentation around an interpolation collapse to a single space, so this common formatting
<span>
{{ total }}
</span>
renders <span> 19.99 </span> with single spaces, not a multi-line mess. When exact text matters (pre-formatted blocks, inline code), keep the mustache tight against its neighbors or use CSS for the spacing.
Finally, the coercion rule cuts both ways for computed text styles: {{ '' + count }} or {{ count.toString() }} are explicit coercions that change nothing for numbers — but a String() around null does differ from interpolation (String(null) is "null" while {{ null }} is blank). That is the whole rule: Vue special-cases only the nullish values, and delegates the rest to JavaScript.
v-text, Escaped by Default, and the One Way Out (v-html)
The v-text directive does the same job as interpolation with one structural difference: it sets the element's entire text content, wiping everything else inside that element.
<template>
<!-- both render Count: 3 when count is 3 -->
<p>Count: {{ count }}</p>
<p v-text="'Count: ' + count"></p>
<!-- v-text destroys the sibling -->
<p v-text="count"><strong>placeholder</strong></p>
<!-- renders as: <p>3</p> — the <strong> is gone -->
</template>
Mechanically, v-text compiles to an assignment of el.textContent on every update, which replaces all child nodes — markup included — with the coerced string. Interpolation, by contrast, owns exactly one text node and leaves its siblings alone. That difference decides usage: interpolation is the default for putting a value inside markup; v-text appears mainly in templates where the element is pure text (a status label, a count badge) or in templates written in the pre-mustache style. Both are fully HTML-escaped — a <script> tag inside a user comment renders as visible text, never as an executable node — and that default is the security property to state plainly in interviews: Vue's text rendering paths cannot inject HTML; only an explicit opt-in can.
The opt-in is v-html, and it deserves its warnings:
<!-- renders the string as HTML — dangerously -->
<div class="comment" v-html="comment.body"></div>
Any string that reaches v-html executes as markup: <img src=x onerror=alert(document.cookie)> in a user comment is a live XSS vector, so v-html is acceptable only for content your server sanitized (or generated) — markdown pipelines that render to trusted HTML, admin-authored CMS fragments. The pattern that keeps the blast radius small: sanitize server-side, keep v-html calls in one well-named component (SafeHtml.vue) so reviewers can grep every occurrence, and never pass request-derived data straight through.
A related property of mustaches closes the loop: they cannot appear inside attributes. <div title="{{ msg }}"> is a compile error, because attribute values are not text-interpolation contexts — the compiler knows them as binding contexts, and the directive for that job is v-bind (a colon), covered in Dynamic Attributes with v-bind. v-text has no attribute equivalent either; everything in this chapter concerns an element's text content.
One directive from the other end of the spectrum is worth one sentence here: v-pre skips compilation for an element and its children, so mustaches inside it render literally. It exists for documentation sites that need to show {{ }} syntax without Vue eating it — a narrow use, but the standard answer when someone asks how the Vue docs render its own examples.
The practical takeaway is a default you can apply without thinking: reach for interpolation first, switch to v-text only when an element is a pure text container, and treat any appearance of v-html in code review as a security question that must have a written answer. Next: Conditional Rendering with v-if — where falsy values stop rendering text and start removing elements entirely.
One coercion case repays a second look because it hides in plain sight: template expressions that build strings from numbers. Concatenating a label and a value coerces with the same String() rule, so a missing total prints the word undefined — the exact artifact interpolation avoids when you bind the parts separately (<span>Total: {{ total }}</span>) and let the text nodes do the composing. Money formatting takes the next step: compute the display string once with Intl.NumberFormat in a helper and bind the result, so every table agrees on decimals and currency symbols instead of each template improvising its own.
Quiz
1. Which mustache content does the Vue template compiler accept?
2. State: `count = 0`, `ok = false`, `name = null`, `tags = []`. What does `<p>[{{ name }}|{{ count }}|{{ ok }}|{{ tags }}]</p>` render?
3. A comment renders via `v-html="comment.body"`. What security statement is accurate?
4. What is the difference between `<p v-text="count">x</p>` and `<p>{{ count }}</p>`?
Flashcards
Question
What is allowed inside `{{ }}`, and what does the compiler do with a violation?
Click to reveal answer
Answer
Exactly one JavaScript expression — member access, ternaries, function calls, optional chaining. Statements (declarations, if/for, assignments, multiple ;-separated statements) are rejected at compile time with 'Error compiling template', before any code runs. Logic moves to script (computed/helpers); structure moves to directives.
Question
State the complete coercion rule for interpolation and its table for common values.
Click to reveal answer
Answer
null/undefined → empty string; everything else → String(value). So 0 → '0', false → 'false', [] → '', [1,2] → '1,2', {} → '[object Object]', Date → its long toString(). Unlike v-if, interpolation has no falsiness — 0 and false always render as text.
Question
Why can interpolated text never cause XSS, and what is the single opt-out?
Click to reveal answer
Answer
Mustaches and v-text write into text nodes / textContent, which the browser treats as plain text — markup in the string displays literally. The only opt-out is v-html, which assigns innerHTML and therefore executes injected markup (e.g. <img onerror=...>); restrict it to server-sanitized or trusted content.
Question
How does v-text differ from interpolation?
Click to reveal answer
Answer
v-text assigns the element's entire textContent on each update, removing every other child node (a placeholder or sibling markup is destroyed). Interpolation compiles to one dedicated text node that is patched while siblings stay — so interpolation composes with surrounding markup and v-text does not.
Question
A template shows literal `{{ user.name }}` on the page — what are the two likely causes?
Click to reveal answer
Answer
The mustache is in a file the Vue compiler never processes — index.html or something under public/ (only .vue template blocks are compiled) — or it sits inside a v-pre subtree. It is not a runtime failure: interpolation is a compile-time construct, so an uncompiled mustache stays plain text forever.
Revision Notes
Key Takeaways
- 1. Interpolation is a compile-time construct: only expressions compile, and each mustache owns exactly one text node that the reactive system patches
- 2. Rendering follows one coercion rule — null/undefined become '', everything else is String(v) — so 0 and false show as text while empty arrays print nothing
- 3. Text paths are HTML-escaped by construction; v-html is the sole opt-in to raw markup and carries the app's XSS risk
- 4. v-text is the same coercion with a different blast radius: it assigns the whole textContent and removes sibling nodes
- 5. Templates run against a whitelisted global set and cannot assign, which is exactly what makes dependency tracking safe and reliable
Interview Tips
- • "Why can't I use an if-statement in a template?" — interpolation evaluates one expression into one value; statements have no such value. Point to the compiler rejecting it, then say where the logic belongs (computed/methods) and where structure belongs (v-if/v-for)
- • "Is Vue interpolation safe against XSS?" — yes: text nodes and textContent are plain text; the only escape is v-html; show judgment by naming the sanitization policy and isolating v-html calls
- • "Why does my template print [object Object]?" — String({}) coercion; the fix is rendering a concrete property or formatting in script — demonstrates you know coercion, not just syntax
- • "0 renders in {{ }} but not in v-if — why?" — interpolation has no falsiness, only String(); v-if uses JS truthiness to decide mounting; that asymmetry is the seed of the conditional-rendering topic
Cheat Sheet
Text Interpolation and Expressions
Core syntax:
<p>{{ count + 1 }}</p> <!-- expression, reactive -->
<p>{{ user.name ?? 'anonymous' }}</p> <!-- optional chaining + ?? -->
<p>{{ formatPrice(price) }}</p> <!-- call a helper exposed by setup -->
<p v-text="'Count: ' + count">x</p> <!-- sets entire textContent -->
Rules:
| Rule | Consequence |
|---|---|
| expressions only | const/if/assignments fail at compile time (Error compiling template) |
| re-evaluates each render | keep mustaches side-effect free |
| globals whitelisted | Math, Date, JSON, Number, Array, Object, String, Boolean, RegExp, console — app values must come via setup/props |
| coercion | null/undefined → ''; everything else → String(v) |
| no falsiness | 0, false render as text (unlike v-if) |
| escapes by default | XSS-proof; v-html is the only raw-HTML path |
| not valid in attributes | <div title="{{ x }}"> is a compile error — use v-bind/: |
Coercion quick table: null→'' · undefined→'' · 0→'0' · false→'false' · []→'' · [1,2]→'1,2' · {}→'[object Object]' · Date→toString().
Escaping ladder: interpolation (safest, composes with markup) → v-text (safe, wipes element) → v-html (raw HTML — sanitize server-side, isolate in one component, grep-able).
Debugging: literal {{ }} on screen ⇒ the file was never compiled by Vue (index.html / public/ / v-pre) — not a runtime issue.