In the previous lesson <task-card> gained four public reactive properties: titulo, estado, prioridad and urgente. All four have something in common: they are part of the task's data, they come "from outside" (from whoever uses the component), and it makes sense that they can be set as HTML attributes. But not everything that needs reactivity inside a component fits that profile. This lesson introduces private internal state, declared with state: true (or the @state decorator), and uses it to give <task-card> the ability to expand and show more detail on click, without that expanded detail being part of the component's public API.
Contents
- Two kinds of reactive data: public property and internal state
- Declaring internal state with
state: true - The
@statedecorator as an alternative - Typical use cases for internal state
- Adding
expandidato<task-card> - A brief note in passing: handling the click
- Two kinds of reactive data: public property and internal state
The properties seen in the previous lesson (titulo, estado, prioridad, urgente) share an underlying characteristic: they are part of the component's public API. They are data that other code — the parent component, or whoever writes the HTML where <task-card> is used — needs to be able to set from outside, either through an HTML attribute or through a JavaScript assignment. That is why it makes sense for Lit to create, by default, an associated HTML attribute for them, as explained in section 3 of the previous lesson.
But a component, as it gains its own behavior, often needs to store data that does not fit that profile: data that only matters to the component itself, to manage its own internal behavior, and that nobody outside the component should need to read, or especially, need to set. A typical example, and exactly the one that will be built in this lesson: whether a task card is "expanded" (showing more detail) or "collapsed" (showing only the summary). That piece of data does not describe the task itself — a task is not "expanded" or "collapsed", it is the card that represents it that has that visual state — and it makes no sense for anyone to write <task-card expandida="true"> in their HTML expecting to control that detail from outside.
For this second kind of data, Lit offers internal state (in Lit's own terminology, internal reactive state), which in static properties is declared with the state: true option instead of type. Internal state is still fully reactive — changing its value triggers an update exactly like a public property — but it never generates an HTML attribute, and Lit marks it, both in its documentation and in development tools, as a piece that belongs to the component's internal implementation, not to its public interface.
| Aspect | Public property (type: String, etc.) |
Internal state (state: true) |
|---|---|---|
| Is it reactive? | Yes | Yes |
| Does it generate an HTML attribute? | Yes, by default | No, never |
| Is it part of the component's API? | Yes | No |
| Who usually sets it? | The code that uses the component (parent, HTML) | The component itself, from within |
Example in <task-card> |
titulo, estado, prioridad, urgente |
expandida |
- Declaring internal state with
state: true
state: trueThe syntax is a minimal variation of what was already seen in the previous lesson: instead of { type: Boolean }, you write { state: true }.
import { LitElement, html } from 'lit';
class TaskCard extends LitElement {
static properties = {
titulo: { type: String },
expandida: { state: true },
};
constructor() {
super();
this.titulo = 'Tarea sin título';
this.expandida = false;
}
render() {
return html`
<article>
<h3>${this.titulo}</h3>
${this.expandida ? html`<p>Detalle adicional de la tarea...</p>` : ''}
</article>
`;
}
}Notice that expandida is still assigned in the constructor, just like any other reactive property, and is still read in render() with this.expandida, exactly like titulo. The only real difference is in the declaration: { state: true } instead of { type: Boolean }. This small change in the declaration is enough for Lit to not generate any expandida attribute in the element's HTML, even though the JavaScript property this.expandida still exists and is still perfectly reactive.
A common convention, although not mandatory, in real Lit code is to prefix the most sensitive internal state field names with an underscore, or at least to avoid any public documentation that invites reading or writing them from outside the component; Lit imposes no technical restriction that prevents accessing elemento.expandida from outside (JavaScript has no true "private" mechanism for fields declared this way, beyond the language's own # fields, which Lit does not use here), but the design intent is clear: it is an internal detail, and treating it as such in the rest of the code avoids coupling the component's external behavior to a piece of data that could change name or shape without notice.
- The
@state decorator as an alternative
@state decorator as an alternativeJust like with @property in the previous lesson, there is an equivalent decorator syntax for internal state:
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('task-card')
class TaskCard extends LitElement {
@property({ type: String })
titulo = 'Tarea sin título';
@state()
expandida = false;
render() {
return html`
<article>
<h3>${this.titulo}</h3>
${this.expandida ? html`<p>Detalle adicional de la tarea...</p>` : ''}
</article>
`;
}
}The @state() decorator does not accept the same options as @property() (it would make no sense to pass it type or attribute, since internal state never has an associated attribute); it simply marks the field as internal reactive state. The runtime result is identical to { state: true } in static properties.
- Typical use cases for internal state
Before applying expandida to <task-card>, it is worth being clear about the general pattern of when to use internal state instead of a public property. Some typical examples, beyond the one that will be built in this lesson:
- An internal counter: for example, how many times a button has been pressed inside the component itself, without that count being part of the data the component describes to the outside.
- An "expanded/collapsed" flag: exactly the case of
<task-card>in this lesson; a custom<details>, a dropdown menu, or any component with a section that is shown or hidden based on an interaction with the component itself. - The state of an in-flight request: a field like
cargando(true/false) while a component waits for a network response, which only matters to the component itself in deciding what to show in the meantime (a "Loading..." text or a visual indicator). - An intermediate value computed from public properties: for example, if a component receives a long list as a public property but internally shows only one page of results at a time, the current page number would be a good candidate for internal state.
The question worth asking whenever deciding between a public property and internal state is: does it make sense for someone, from outside the component, to want to read or set this value as part of how they use the component? If the answer is yes, it is a public property. If the value only makes sense as an implementation detail of the component itself, it is internal state.
- Adding
expandida to <task-card>
expandida to <task-card>With the criterion from the previous section, expandida clearly fits as internal state: it describes whether the card, as a piece of interface, is showing its expanded detail, not a piece of data about the task itself. Pick up src/components/task-card.js as it was left in the previous lesson and add the new state:
import { LitElement, html } from 'lit';
class TaskCard extends LitElement {
static properties = {
titulo: { type: String },
estado: { type: String },
prioridad: { type: Number },
urgente: { type: Boolean },
expandida: { state: true },
};
constructor() {
super();
this.titulo = 'Tarea sin título';
this.estado = 'pendiente';
this.prioridad = 3;
this.urgente = false;
this.expandida = false;
}
alternarExpandida() {
this.expandida = !this.expandida;
}
renderInsigniaEstado() {
if (this.estado === 'hecha') {
return html`<span class="insignia insignia--hecha">✓ Hecha</span>`;
}
if (this.estado === 'en-progreso') {
return html`<span class="insignia insignia--progreso">◐ En progreso</span>`;
}
return html`<span class="insignia insignia--pendiente">○ Pendiente</span>`;
}
render() {
return html`
<article @click="${this.alternarExpandida}">
<h3>${this.titulo}</h3>
${this.renderInsigniaEstado()}
<p>Prioridad: ${this.prioridad}</p>
${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
${this.expandida
? html`
<div class="detalle">
<p>Estado interno: la tarjeta está expandida.</p>
<p>Aquí, más adelante en el curso, podría mostrarse una descripción larga, un histórico de cambios, o comentarios de la tarea.</p>
</div>
`
: ''}
</article>
`;
}
}
customElements.define('task-card', TaskCard);The alternarExpandida() method inverts the boolean value of this.expandida with the negation operator (!this.expandida); since expandida is a reactive internal state, that assignment goes through the same setter mechanism explained in the previous lesson and triggers an update, exactly like with any public property. The template uses the ternary operator seen in module 2 to show or hide the detail block based on the value of this.expandida.
- A brief note in passing: handling the click
You may have noticed the @click="${this.alternarExpandida}" syntax inside the <article> tag in the previous example: it is the way Lit lets you listen to DOM events directly from a template, with an @ prefix followed by the event name and, in quotes, a reference to the function that should run when that event occurs (in this case, a browser click event on the <article> itself).
This course covers events in depth in module 5, "Events and Communication Between Components", where this syntax will be explained in detail, along with how to dispatch a component's own custom events (for example, so that <task-card> can notify <task-list> that the user wants to mark a task as completed), and how to correctly handle the value of this inside the handler method. Here only the bare minimum is used — a native browser event (click) on a method that no longer receives any argument from the event itself — in order to trigger the change of expandida and thus complete the internal state example; there is no need to go deeper on this point of the course.
What matters for this lesson is to note that, whatever the origin of the change (a user click, a network response, a timer...), the reactivity mechanism is exactly the same one you already know: a new value is assigned to a property or to a reactive state, and Lit takes care of scheduling the corresponding update.
Common Mistakes and Tips
- Declaring a piece of data as a public property when it is actually internal state: if
expandidais declared with{ type: Boolean }instead of{ state: true }, the component will keep working (the reactivity is the same), but anexpandidaHTML attribute will be unnecessarily generated, becoming, without intending it, part of the component's public API and inviting a use that makes no sense (<task-card expandida></task-card>). - Expecting
state: trueto prevent access from outside: as explained in section 2,state: trueis a design convention backed by Lit (no attribute, marked as internal in development tools), but not a real JavaScript privacy mechanism. Nothing technically prevents outside code from reading or writingelemento.expandida; it is simply not documented, nor expected to be used that way. - Forgetting to initialize the internal state in the
constructor: exactly the same mistake as with the public properties from the previous module; if no initial value is assigned tothis.expandidain theconstructor, its value will beundefinedon the first render, which can produce unexpected behavior in the template's conditional expressions. - Putting too much business logic into the internal state of a presentation component: a component like
<task-card>can perfectly well have its own visual state (expandida), but if it starts accumulating internal state related to business data (for example, its own copy of the full list of tasks), that is a sign that responsibility probably belongs in a parent component and should arrive as a public property, not be duplicated as internal state.
Exercises
- Add a new internal state
vecesExpandidato<task-card>, of numeric type, initialized to0, that increases by one every timealternarExpandida()is called (regardless of whether the result is expanding or collapsing). Show it in the template purely for debugging purposes, for example as<p>Expandida ${this.vecesExpandida} veces</p>. - Explain, using the table from section 1, why it would not make sense to declare
tituloas{ state: true }instead of{ type: String }. - Imagine a
<user-avatar>component (one of the components planned for TaskFlow later in the course) that shows a user's picture and, while that picture has not yet finished loading, a placeholder background color. Decide, reasoning with the criterion from section 4, whether the piece of data "the picture has finished loading" (true/false) should be a public property or internal state.
Solutions
static properties = {
// ...previous properties...
expandida: { state: true },
vecesExpandida: { state: true },
};
constructor() {
super();
// ...
this.expandida = false;
this.vecesExpandida = 0;
}
alternarExpandida() {
this.expandida = !this.expandida;
this.vecesExpandida = this.vecesExpandida + 1;
}Since both assignments happen within the same synchronous function, Lit batches them (as explained in the render cycle lesson in module 2) and runs render() only once, with both values already updated.
-
titulois a piece of data that describes the task itself and that needs to be settable from outside the component: whoever uses<task-card>(whether by writing the HTML attribute directly or by assigning the property from<task-list>) needs to indicate which title to show. If it were declared as internal state (state: true), Lit would not generate any associated HTML attribute, and it would be impossible to set the title through<task-card titulo="...">; the only remaining option would be assigning it via JavaScript after creating the element, which unnecessarily limits how the component can be used. -
It is a good candidate for internal state: the fact that the specific picture has finished loading is an implementation detail of how
<user-avatar>manages its own resource loading, not a piece of data that describes the user, nor something that whoever uses the component would need to set from outside (nobody would write<user-avatar imagen-cargada="true">expecting to control that aspect). It fits the criterion from section 4: it only matters to the component itself, in order to internally decide what to show in the meantime.
Conclusion
In this lesson you have learned to distinguish between public properties, which are part of a component's API and usually have an associated HTML attribute, and private internal state, declared with state: true (or @state), reactive just like any property but without generating any attribute and without belonging to the component's outward-facing interface. You have applied this distinction to <task-card>, which can now expand and collapse through an internal state expandida, toggled with a minimal click handler whose full detail is reserved for module 5.
So far, every property declared — both public and internal — has used the simplest types: String, Number and Boolean. In the next lesson, "Types of Properties and Custom Converters", you will see the full catalog of types Lit supports out of the box, understand in detail how it converts between the text of an HTML attribute and the JavaScript value of the property, and learn to define your own converter for a data type Lit cannot natively interpret, applying it to a new fechaLimite property on <task-card>.
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
