Lit In Practice

How to fetch data in connectedCallback, manage loading states, render lists with .map(), and update items in a Lit component without breaking reactivity.

June 13, 20264 min read3 / 3

Once the reactive model from the libraries comparison clicked, I wanted to see it hold up under real conditions. A counter is a clean demo. What I needed was the full picture: fetch data, handle loading, render a list, let users change individual items.

Those four patterns together are where Lit either earns its keep or doesn't.

Fetching in connectedCallback

connectedCallback is the right hook for async work. The element is in the DOM, the lifecycle is ready. The one rule: call super.connectedCallback() first.

JavaScript
async connectedCallback() { super.connectedCallback(); this.tasks = await fetchTasks('/api/tasks'); }

super.connectedCallback() wires up Lit's internal observation machinery. Skip it and property changes may not trigger re-renders correctly. It is the same reason you call super() first in a constructor -- the parent class needs to set up before your code runs.

Loading State

Mark the loading flag as { state: true } to keep it private:

JavaScript
static properties = { tasks: { type: Array }, loading: { state: true }, }; async connectedCallback() { super.connectedCallback(); this.loading = true; this.tasks = await fetchTasks('/api/tasks'); this.loading = false; }

Guard the main render by returning early rather than nesting conditionals:

JavaScript
render() { if (this.loading) { return html`<p>Loading...</p>`; } return html` <ul>${this.tasks.map(task => html` <li>${task.title}</li> `)}</ul> `; }

Returning early from render() is cleaner than nested ternaries inside the template. The loading path exits immediately. The main template stays flat and readable.

Lit component data flow: connectedCallback fetch cycle and reactive render loop ExpandLit component data flow: connectedCallback fetch cycle and reactive render loop

Rendering Lists

.map() inside html`` is the idiomatic pattern. Each call returns an html`` result and Lit collects them:

JavaScript
${this.tasks.map(task => html` <li> <span>${task.title}</span> <button @click=${() => this.toggle(task)}> ${task.done ? 'Mark undone' : 'Mark done'} </button> </li> `)}

Lit handles the DOM diff. When this.tasks changes, only the items whose expressions changed get updated in the DOM -- not the full list.

Mutating Items Without Breaking Reactivity

This is the pattern that trips people up:

JavaScript
// Does NOT trigger a re-render: this.tasks[0].done = true; // Does trigger a re-render: this.tasks = this.tasks.map(task => task.id === target.id ? { ...task, done: !task.done } : task );

Lit watches for reference changes on reactive properties, not deep equality. Mutating an existing object leaves the array reference the same. Lit sees nothing changed.

Assigning a new array reference tells Lit something changed and schedules a re-render. The map-and-spread pattern creates a new array where each item is either unchanged (same object reference) or replaced (new spread object). Lit's partial update system does the rest.

Deriving Values at Render Time

Totals and counts should not live as separate reactive properties. Store them separately and they will eventually drift out of sync with the source array.

JavaScript
render() { const done = this.tasks.filter(t => t.done).length; const pending = this.tasks.length - done; return html` <p>${done} done · ${pending} remaining</p> ... `; }

Derive at render time and the values are always in sync. The only source of truth is this.tasks. Everything else is a calculation.

The @customElement Decorator

An alternative to customElements.define() at the bottom of the file:

JavaScript
import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; @customElement('task-list') class TaskList extends LitElement { @property({ type: Array }) tasks = []; @state() loading = false; }

@customElement puts the registration at the top. @property and @state replace static properties. The output is identical either way. The trade-off: decorators require TypeScript with experimentalDecorators or a transpiler in the build pipeline. Without that, stick with static properties and customElements.define at the bottom.

Getting Started with open-wc

npm init open-wc scaffolds a Lit project or full application with linting, web test runner, Storybook, and accessibility auditing pre-configured. The scaffold also generates a custom-element.json manifest -- a machine-readable description of the component's properties and events that Storybook and IDE tooling use to infer the API.

The accessibility test alone is worth the scaffold: it mounts the component as a fixture and runs an axe audit on every test pass. Accessibility feedback before you open a browser.

The state management patterns above work well for a single component. The challenge surfaces when two components need the same data -- and that is where web components have a real gap the ecosystem is still filling.

The Essentials

  1. Always call super.connectedCallback() before async work. It wires up Lit's reactive machinery.
  2. { state: true } properties are private. Loading flags, selection state, anything the outside world should not set directly.
  3. Return early from render() for loading and error states. Nested conditionals inside the template get messy fast.
  4. .map() inside html`` returns html`` -- Lit collects the results and diffs the DOM on each update.
  5. Assign a new array reference to trigger reactivity. Mutating an item in-place does not signal Lit that anything changed.
  6. Derive totals and counts at render time. Store only the source data as state.

Further Reading and Watching