In the previous module you wrote several templates with the html function, but always with completely fixed content: the same title, the same status, the same assignee on every render. The time has come to understand what that html function really is and, above all, what happens under the hood when its content stops being static. This lesson opens module 2 by explaining the internal mechanism of Lit's template engine: how a template is analyzed, how the DOM is updated when data changes, and why this approach is much more efficient than the classic alternatives of manipulating the DOM by hand or rebuilding it with innerHTML.
Contents
htmlis not a text template: it's a function- Tagged template literals: the foundation of
html - How Lit knows which parts are dynamic
- Updating the DOM without a Virtual DOM
- Why this is different (and better) than
innerHTML - First interpolation in
<task-card> - Important: this still isn't full reactivity
html is not a text template: it's a function
html is not a text template: it's a functionSo far, every time you've written something like this:
render() {
return html`
<article>
<h3>Preparar la demo del sprint</h3>
<p>Estado: En curso</p>
</article>
`;
}it's easy to think of html\...`as if it were simply a text string with HTML inside, something similar to writing". That intuition is reasonable at first glance, but it's incorrect, and it hides exactly the mechanism that makes Lit fast and practical to use. html` is, in fact, a JavaScript function, and what gets passed between the backticks isn't a normal string, but something called a tagged template literal, a feature that has been part of the JavaScript standard itself for years, with no dependency on Lit whatsoever.
When the JavaScript engine encounters html\
Hola
`, it does not concatenate text and call htmlwith the result. Instead, it invokes thehtmlfunction with structured information about the template: on one hand, an array with the literal ("static") text chunks, and on the other, the values that have been interpolated inside${}` (if any). This distinction —fixed chunks on one side, dynamic values on the other— is the central piece of everything that makes Lit's template engine special.
- Tagged template literals: the foundation of
html
htmlTo properly understand what html does, it's useful to first see how a tagged template literal works with a sample function, without Lit involved:
function miEtiqueta(trozos, ...valores) {
console.log(trozos); // ["Hola ", ", tienes ", " tareas"]
console.log(valores); // ["Ana", 3]
}
const nombre = 'Ana';
const numeroDeTareas = 3;
miEtiqueta`Hola ${nombre}, tienes ${numeroDeTareas} tareas`;When this code runs, trozos receives an array with the three text fragments that remain between the interpolations ("Hola ", ", tienes " and " tareas"), while valores receives, in a separate array, the two interpolated values ("Ana" and 3). This separation is exactly what makes it possible for a tagged function to "know" which parts of the template are literal (they never change) and which are expressions (they can change).
Lit's html function is, in essence, a tagged function of this same kind, specialized in treating the first array as HTML and the second as data that must be inserted at specific points within that HTML. The difference from the miEtiqueta example is that html doesn't just log those values to the console: it builds from them an internal object, called a TemplateResult, which describes the template in a way that Lit can process efficiently.
- How Lit knows which parts are dynamic
Here lies the key to the whole mechanism. Since the literal text chunks always arrive separately from the interpolated values, Lit can, the first time it sees a particular template, analyze only its fixed "skeleton" (the text chunks) and figure out exactly at which positions of the HTML dynamic values will appear. That analysis is done only once per template, not on every render.
The process, simplified, is as follows:
- The first time Lit encounters a given
html\...`template (identified internally by its fixed text chunks), it builds a real HTML` element with special markers in the gaps where interpolations used to be. - Lit walks through that
<template>and precisely notes where those gaps are: they can be the content of a text node, the value of an attribute, or an entire node. - This analysis is cached, associated with that specific template. If the same component renders again with the same template structure (only the interpolated values changing), Lit does not repeat the analysis: it directly reuses the already-computed map of gaps.
- On every subsequent render, Lit only needs to clone the
<template>(a very cheap operation in the browser) and place the new values exactly in the already-identified gaps, without re-parsing the entire HTML.
This idea —separating, once and for all, the fixed "skeleton" from the variable "gaps"— is the reason Lit can afford extremely fast updates: in a normal render, not a single HTML tag is re-parsed; only the new values are compared against the previous ones at each gap and, if they've changed, exactly that point of the real DOM gets updated.
- Updating the DOM without a Virtual DOM
If you've used other UI libraries (React, Vue...) you may be familiar with the concept of a "Virtual DOM": an in-memory representation, like a tree of JavaScript objects, of what the interface should look like. On every update, those libraries generate a whole new virtual tree and compare it (through a "diffing" algorithm) against the previous virtual tree, to compute the minimal changes that must be applied to the real DOM.
Lit follows a different, more direct approach, precisely thanks to the analysis described in the previous section:
| Aspect | Virtual DOM approach | Lit's approach |
|---|---|---|
| What gets generated on each render | A complete tree of objects representing the whole interface | Only the new values to be inserted into the already-identified gaps |
| How changes are detected | By comparing (diffing) the new tree against the previous tree, node by node |
By directly comparing each gap's new value against that same gap's previous value |
| Cost of a render with no real changes | The whole new tree is generated anyway, even if the diffing result is "touch nothing" | The gaps are evaluated, no structure is rebuilt, and the DOM isn't touched if the values haven't changed |
| Where structural knowledge lives | In the virtual tree, recalculated on every render | In the <template> already analyzed and cached since the first render |
In other words: Lit doesn't need to "guess" what has changed by comparing two full trees, because it already knows, from the first analysis of the template, exactly where the points that can change are. It only has to check those specific points. This doesn't mean one approach is "bad" and the other "good" in absolute terms —they're different strategies with their own advantages— but it does explain why Lit can be such a lightweight library: it doesn't need to implement or ship a full tree-diffing algorithm in its code.
- Why this is different (and better) than
innerHTML
innerHTMLAnother common alternative, especially in code without any library, is to rebuild the HTML as a text string and assign it through innerHTML:
// Enfoque manual con innerHTML, SIN Lit (solo con fines comparativos)
function actualizarTarjeta(contenedor, titulo, estado) {
contenedor.innerHTML = `
<article>
<h3>${titulo}</h3>
<p>Estado: ${estado}</p>
</article>
`;
}This code "works", in the sense that it shows the correct HTML, but it has serious problems that Lit's template engine avoids by design:
- It destroys and recreates the entire DOM tree on every update: when you assign
innerHTML, the browser discards the existing nodes inside the container and creates brand-new nodes from the text. This is costly in terms of performance and, on top of that, it causes any internal state of those nodes to be lost (for example, the focus of a form field, or the text a user was selecting). - It re-parses the entire HTML on every call: the browser has to interpret the whole text string as HTML again each time, with no caching or reuse of previous analyses whatsoever.
- It opens the door to HTML injection vulnerabilities: if
tituloorestadocontained untrusted content (for example, text freely typed by a user with<script>tags inside),innerHTMLwould interpret it as real HTML, potentially executing it. Lit, by contrast, inserts interpolated values as safe text by default, without interpreting them as HTML (unless a mechanism designed specifically for that case is used explicitly, which will be covered later in the course).
Lit, thanks to the template cache from section 3, updates only the specific text nodes or attributes that have changed, keeping the rest of the DOM tree intact. This is a fundamental behavioral difference, not just a performance one.
- First interpolation in
<task-card>
<task-card>With this foundation in place, you can now take the module's first practical step: interpolating a value inside <task-card>. Bring back the component from the "Your First Lit Component" lesson and modify it like this:
import { LitElement, html } from 'lit';
class TaskCard extends LitElement {
constructor() {
super();
// Un campo de instancia simple, todavía sin el sistema
// reactive property system (that arrives in module 3).
this.titulo = 'Preparar la demo del sprint';
}
render() {
return html`
<article>
<h3>${this.titulo}</h3>
<p>Estado: En curso</p>
<p>Asignada a: Ana</p>
</article>
`;
}
}
customElements.define('task-card', TaskCard);The change from the previous version is small but important: in the constructor, this.titulo is defined as a plain JavaScript instance field, and in render(), ${this.titulo} is interpolated inside the <h3>, instead of the fixed text "Preparar la demo del sprint". When you reload the page, the visual result is identical to before: "Preparar la demo del sprint" is still shown, because that's the value that has been assigned to this.titulo. The difference isn't in what you see, but in how it's generated: now the title comes from a JavaScript expression (${this.titulo}), not from text hardcoded directly into the template.
You can verify this by modifying the value by hand, for example in the browser console, first getting a reference to the element:
// Desde la consola del navegador (con <task-card> ya en la página):
const tarjeta = document.querySelector('task-card');
tarjeta.titulo = 'Revisar el backlog';If you run this and nothing visible happens on screen, don't worry: that's exactly the expected behavior at this point in the course, and it's explained in the next section.
- Important: this still isn't full reactivity
It's essential to clarify a nuance before moving on in the module, so as not to create the wrong expectations: this.titulo in the previous example is, for now, a plain JavaScript instance field, with no relation yet to the reactive property system that Lit offers. If you ran the console code from the previous section (tarjeta.titulo = 'Revisar el backlog'), you will have seen that the card on screen does not change, even though the value of this.titulo has indeed been updated internally.
This happens because, as explained in section 3, Lit only re-runs render() and updates the DOM when it detects that a reactive property has changed, something that hasn't yet been declared in this component. Declaring titulo as a real reactive property (with static properties, as noted in the anatomy lesson of module 1) is precisely the core content of module 3, "Reactive Properties and State".
For the rest of this module 2, you will keep using plain instance fields like this.titulo so you can focus, without distractions, on how templates are written: how to interpolate different types of values, how to render conditionally, how to render lists, and how the render cycle works. render() itself will be run explicitly whenever you need to see changes (for example, by manually calling a method you'll see in the module's last lesson), but automatic updating when a value changes —true reactivity— is module 3's territory. Keep this in mind: if something doesn't update "on its own" in the coming examples, it's intentional, not a bug.
Common Mistakes and Tips
- Thinking that
htmlreturns a text string: this is a very common mistake when starting out with Lit.html\...`returns aTemplateResultobject, not a string. If you try to concatenate it with+` or use it where text is expected, you won't get the expected result. - Expecting that changing a plain instance field will update the screen: as explained in section 7, this is exactly what happens at this point in the course, on purpose. Automatic updating arrives with module 3's reactive properties.
- Confusing template caching with data caching: what Lit caches is the analysis of the template's structure (where the gaps are), not the specific values that have been interpolated at any given moment. Every render still uses the current values.
- Using
innerHTML"by hand" inside a Lit component to mix approaches: if you need to show dynamic HTML, always do it through thehtmlfunction and its interpolations, not by mixing direct DOM manipulation with Lit's template system; mixing both approaches can confuse Lit about which nodes it manages and which it doesn't.
Exercises
- Explain in your own words (two or three sentences) what the two arrays are that a tagged function like
htmlreceives, and why that separation matters for Lit's performance. - Add to
<task-card>a second instance field,this.estado, initialized to'En curso'in the constructor, and interpolate it in the template, replacing the fixed text "En curso" in the status paragraph. - From the browser console, change
tarjeta.estadoto another value and confirm that, indeed, the screen doesn't update. Write a comment explaining why this happens, relating it to what was explained in section 7.
Solutions
-
A tagged function like
htmlreceives, on one hand, an array with the literal text fragments of the template (the parts that never change) and, on the other, an array with the values that have been interpolated with${}. This separation matters because it lets Lit analyze the template's fixed "skeleton" (where the gaps are) just once, and on subsequent renders, reuse that analysis without re-parsing the HTML, inserting the new values directly into the already-known gaps.
import { LitElement, html } from 'lit';
class TaskCard extends LitElement {
constructor() {
super();
this.titulo = 'Preparar la demo del sprint';
this.estado = 'En curso';
}
render() {
return html`
<article>
<h3>${this.titulo}</h3>
<p>Estado: ${this.estado}</p>
<p>Asignada a: Ana</p>
</article>
`;
}
}
customElements.define('task-card', TaskCard);- When you run
tarjeta.estado = 'Bloqueada'from the console, the object's internal property changes (you can verify this withconsole.log(tarjeta.estado), which will show'Bloqueada'), but the screen still shows "Estado: En curso". This happens becauseestado, just liketitulo, is at this point a plain JavaScript instance field, not a reactive property registered with Lit; Lit only re-runsrender()when it detects changes in properties it has been told to watch, which will be configured withstatic propertiesin module 3.
Conclusion
In this lesson you opened module 2 by taking apart the internal mechanism of the html function: it's a standard JavaScript tagged function that separates a template's fixed chunks from its dynamic values, which lets Lit analyze the structure just once and, on every subsequent render, update only the specific gaps of the real DOM, without needing a Virtual DOM or rebuilding the HTML with innerHTML. You also took the first practical step by interpolating plain instance fields in <task-card>, making it clear that this still doesn't trigger any automatic update: that piece arrives with module 3's reactive properties.
In the next lesson, "Expressions and Interpolation in Templates", you'll dig deeper into the different types of values that can be interpolated inside an html template —text, attributes, DOM properties with .prop, boolean values with ?attr— and you'll expand <task-card> to show several fields at once, including a first glimpse of more elaborate JavaScript expressions inside ${}.
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
