ha:lit-patterns
Build ha-frontend Lit components: async data via @lit/task, WebSocket subscriptions for real-time (hass.connection / src/data collections), reactive property change handling, ha-form editors/dialogs/f
SKILL.mdskills/lit-patterns/SKILL.md
GitHubha-frontend Lit Patterns Reference
Integration (Python) side: config-flow forms are voluptuous schemas + selectors — use the
config-flowskill; data fetching/entities live in a coordinator + typedruntime_data— use thecoordinator-patternsandentity-platformsskills. Do not hand-roll ad-hoc field checks. This reference covers the ha-frontend (Lit) side only:ha-*components, card/panel/dialog authoring, and thehassobject.
Reference for building with Lit 2+ (LitElement 3.0) in the home-assistant/frontend codebase. Provenance: frontend-lit-docs.md (official docs snapshot 2026-07-12) + 4,268 maintainer review rows (review-patterns-frontend.md, 2024-07..2026-07). Every rule carries a law ID (F/FS series from docs/iron-laws.md + docs/strong-defaults.md) and ≥1 PR citation.
CAVEAT (hass narrowing): The docs show a monolithic
hassproperty passed everywhere. 2026 reviews push the opposite direction — narrow to slices (HomeAssistant["states"],localize) or Lit context (@consumeLocalize, registries context). Docs lag; reviews lead. See FS10 below. Prefer narrowed params in new code.
MANDATORY: Impeccable Skill for UI Work
Before writing or modifying ANY UI code (components, templates, styling, layout):
- Check the available-skills list for the
impeccableskill. - If present: you MUST invoke it (Skill tool) BEFORE implementing. It owns design quality — this reference covers Lit mechanics only, not visual/UX craft.
- If absent: STOP and prompt the user to install it (e.g.
impeccablefrom the Anthropic skills marketplace via/plugin) before proceeding. Continue without it only if the user explicitly declines.
This is not optional — skipping it on UI work is a skill failure.
Iron Laws — Never Violate These
- NO UNCONDITIONAL DATA LOADS IN
connectedCallback—connectedCallbackcan run more than once (detach/reattach). Default:@lit/task'sTaskwith anargsgate, or subscribe with symmetric teardown indisconnectedCallback. FS4 — "should not do this inconnectedCallbackas it could happen more than once" (bramkragten, PR 21933) - ALWAYS USE
repeat()(OR THE VIRTUALIZER) FOR LISTS — Plain.map()inhtmlre-renders the whole list on every update.repeat()with a key function = minimal DOM diff;@lit-labs/virtualizer= O(viewport) memory for huge lists. FS1/FS15 (render efficiency + memory) — see thelit-render-auditskill - SUBSCRIBE IN
connectedCallback/SubscribeMixin, TEAR DOWN SYMMETRICALLY — Guardhass.connection.subscribe*/ collection subscriptions sodisconnectedCallbackcleanup runs before a reconnect resubscribes. Dialogs are the exception: neverSubscribeMixinin a dialog (dialogs are never removed). FS4/FS5 — "We can not useSubscribeMixinon a dialog, as dialogs are never removed" (bramkragten, PR 27284) - EXTRACT VALUES BEFORE TASK CLOSURES / MEMO ARGS —
Task's task function is a closure; pass reactive inputs explicitly viaargsinstead of readingthis.fooinside the body. Same formemoizeOne: pass every input (includinglocalize) as an argument. FS2 — "Object.valuesgenerates a new object each time and defeats the memoization" (MindFreeze, PR 29636) - GATE DERIVED WORK ON THE RELEVANT CHANGE — Route/param changes re-trigger the
Taskviaargs;willUpdate/updatedwork is gated onchangedProps.has(...)orshouldUpdate. Never run per-hass-update work for unrelated state. FS1 — "This fires on everyhasschange which is way to often" (MindFreeze, PR 28672) - NARROW THE
hassOBJECT — DON'T PASS THE WHOLE THING INTO HELPERS — Take slices (HomeAssistant["states"],localize) or Lit context; extract plain data before calling a helper. FS10 — "we would like to avoid to pass the hass object around everywhere" (wendevlin, PR 29788) - CHECK CONFIG/VALIDATION ERRORS BEFORE UI DEBUGGING — Silent card-editor save = check the
ha-formschema / config validation first, not CSS/JS. Broken stored config never validating is a hard stop (FS12) — "that PR breaks that behavior and should not be merged" (piitaya, PR 52596) - EVERY CONFIG FIELD IS IN THE
ha-formSCHEMA — A card/flow field the user must set has an entry in the.schemaarray with the right selector; nothing is silently omitted or left as a bare string. PS15 (config-flow craft, selectors) — "Use text selectors, not bare strings." (emontnemery, PR 158228) - NEVER LAZILY INITIALIZE LIFECYCLE-CRITICAL STATE — Don't guard assignment of derived state with
??=or "set once" checks; re-derive on the relevantchangedPropsso stalehass/locale never survives. FS4 — reassign symmetrically,connectedCallbackruns every time - HANDLE ERRORS VISIBLY — A bare
catchthat swallows an error leaves the UI stuck. Wrap async UI actions in try/catch, surface withha-alert/toast, reset busy flags infinally. FS11 — "I think we should try catch this and show an error when it fails." (wendevlin, PR 24277)
Always-On Frontend Laws
These apply to every template you touch — the judge blocks (F-series) or flags (FS-series) on them:
| Law | Rule | Citation |
|---|---|---|
| F1 | Every user-facing string goes through hass.localize with a key in src/translations/en.json |
wendevlin, PR 22594 |
| F2 | Never create objects/arrays/schemas/functions inside render() — hoist to module consts or memoize |
piitaya, PR 51401 |
| F3 | Use the in-house ha-* component — never raw <button>/<input>, mwc-*, or ad-hoc widgets |
piitaya, PR 26091 |
| F4 | Style with theme tokens (--primary-color, --ha-space-*, --ha-border-radius-*, --ha-font-size-*) — never hardcoded colors/spacing |
piitaya, PR 24843 |
| F5 | Copy arrays/objects before modifying (spread, not push/assign) so Lit sees the change | balloob, PR 23531 |
| F6 | Import every custom element the template uses — don't rely on incidental registration | wendevlin, PR 25425 |
| F7 | Return Lit nothing (imported from lit) for empty render branches — not ""/undefined |
wendevlin, PR 22579 |
| F8 | Respect the browser floor: no CSS nesting (iOS 12), no structuredClone (use deep-clone-simple), no substr |
wendevlin, PR 24301 |
| F9 | Compose translator-safe strings: whole sentences with placeholders/ICU select — never concatenate fragments | silamon, PR 29104 |
| F10 | All formatting/sorting is locale-aware: format*/Intl with hass.locale, stringCompare for sorting |
MindFreeze, PR 30193 |
| FS6 | Communicate upward via fireEvent DOM events declared in HASSDomEvents; dialogs take callbacks |
bramkragten, PR 26011 |
| FS7 | RTL: logical properties (margin-inline-start, start/end), --scale-direction for transforms |
wendevlin, PR 51990 |
| FS9 | Mobile safe areas: var(--safe-area-inset-*) in bottom-anchored/fullscreen layouts; 100dvh over 100vh |
wendevlin, PR 30081 |
| FS13 | Layering: domain/WS logic in src/data/<domain>.ts, generic helpers in src/common; components never import from panels; dialogs lazy-load |
MindFreeze, PR 29759 |
Memory Impact
| Pattern | 3K items | 10K entities × 10K rows |
|---|---|---|
.map() in html |
~5.1 MB | ~10+ GB (browser memory, per open tab) |
repeat() / virtualizer |
~1.1 MB | Minimal (viewport-bounded) |
Decision: Lists with >100 items → Use repeat() with a key; >1000 rows → reach for @lit-labs/virtualizer. Unbounded caches must be capped or use WeakMap (FS15 — "there is no cap on it. It can run out of memory", balloob, PR 52657).
Quick Patterns
Async Tasks (CRITICAL)
import { Task } from "@lit/task";
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { fetchDeviceConfig } from "../data/my_domain";
@customElement("my-device-panel")
export class MyDevicePanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public deviceId = "";
private _deviceTask = new Task(this, {
task: async ([hass, deviceId], { signal }) =>
fetchDeviceConfig(hass, deviceId, signal),
args: () => [this.hass, this.deviceId] as const,
});
protected render() {
return this._deviceTask.render({
pending: () => html`<ha-spinner></ha-spinner>`,
complete: (device) => html`<h1>${device.name}</h1>`,
error: () =>
html`<ha-alert alert-type="error"
>${this.hass.localize("ui.panel.my_domain.load_error")}</ha-alert
>`,
});
}
}
repeat() for Lists
import { repeat } from "lit/directives/repeat";
protected render() {
return html`
<ha-md-list>
${repeat(
this._items,
(item) => item.id,
(item) => html`<ha-md-list-item>${item.name}</ha-md-list-item>`
)}
</ha-md-list>
`;
}
// Insert/update/delete — copy the backing array (F5), let repeat() diff by key
this._items = [newItem, ...this._items];
this._items = this._items.map((i) => (i.id === updated.id ? updated : i));
this._items = this._items.filter((i) => i.id !== deletedId);
WebSocket / Entity Subscription with Cleanup Guard
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { subscribeMyDomain, type MyItem } from "../data/my_domain";
@customElement("my-live-panel")
export class MyLivePanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _items: MyItem[] = [];
private _unsub?: UnsubscribeFunc;
public connectedCallback() {
super.connectedCallback();
if (!this._unsub && this.hass) {
this._unsub = subscribeMyDomain(this.hass.connection, (items) => {
this._items = items;
});
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsub?.();
this._unsub = undefined;
}
}
Prefer SubscribeMixin's hassSubscribe() for panels/cards where the subscription is stable. Never use SubscribeMixin in a dialog (FS5).
Gating Derived Work on the Relevant Change
import { memoizeOne } from "memoize-one";
// Pass every input (incl. localize) as an argument — FS2
private _computeRows = memoizeOne(
(states: HomeAssistant["states"], localize: LocalizeFunc) => /* … */
);
protected willUpdate(changedProps: PropertyValues) {
// Gate on the relevant change — FS1
if (changedProps.has("deviceId")) {
this._resetSelection();
}
}
Cache-Backed Startup Paint
Framework internals (audience: framework-development) — this pattern is for
home-assistant/frontendframework work, not card authoring.
Startup-visible state (theme, language) keeps a localStorage cache so the first paint doesn't flash the default; the backend value overwrites the cache as soon as the WebSocket delivers it. Do not add anything to the startup hot path that isn't strictly required — balloob holds veto here (FI-1, extends FS13): "Putting extra imports in the hot path of the frontpage needs to be carefully considered." (PR 28036). See references/async-streams.md.
Navigation Decision Tree
Same route, different query params? → replace URL + let willUpdate re-derive
Different route, client-side? → navigate(path) (src/common/navigate)
Full page load / different origin? → location.href = url (full reload)
navigate() pushes onto the history stack; every push must be popped on all exit paths (see references/pubsub-navigation.md).
Component Decision Tree
Does the component need BOTH internal state AND event handling?
│
├── YES → Does it encapsulate PANEL/DIALOG logic (not just presentation)?
│ ├── YES → Stateful LitElement with @state ✅
│ └── NO → Presentational component driven by @property, fires events up
│
└── NO → Presentational component (props in, ha-* events out) ✅
Official guidance: props-down / events-up. @state() for internal reactive fields (anything async-assigned or rendered); @property only for the public API (FS3 — "this shouldn't be a property. make it a boolean @state", wendevlin, PR 27020).
Common Anti-patterns
| Wrong | Right |
|---|---|
Loading data unconditionally in connectedCallback |
Use @lit/task, or subscribe + guard + tear down |
.map() in html for lists |
repeat() with a stable key, or <lit-virtualizer> |
| Subscribing without a cleanup guard | Guard in connectedCallback, tear down in disconnectedCallback |
SubscribeMixin in a dialog |
Subscribe elsewhere; dialogs take callbacks (FS5) |
Passing the whole hass to a helper |
Narrow to the slice/localize you need (FS10) |
Object/array/schema built in render() |
Module constant or memoizeOne (F2) |
| Literal English text in a template | hass.localize("ui.…") with an en.json key (F1) |
Raw <button>/mwc-button |
ha-button / the matching ha-* element (F3) |
Mutating @state/hass data with push/index-assign |
Spread into a new array/object (F5) |
return ""/undefined from a render branch |
return nothing (F7) |
Framework internals (audience: framework-development)
These sections apply only to
home-assistant/frontendframework work (src/entrypoints,src/state,src/mixins,src/common,src/util, build/translation pipeline). They are NOT for authoring cards, panels, or custom components. Keep them distinct from the authoring laws above. Source:docs/framework-dev-laws.md(FI-series) +review-patterns-frontend-internals.md.
- FI-1 — Protect the startup hot path (IRON, extends FS13): no new static imports reachable from
src/entrypoints/core.ts/app.tsor the frontpage render path; dialogs, editors, and heavy libs (echarts modules) load via dynamicimport(). balloob has reverted a merged PR himself over this. "Putting extra imports in the hot path of the frontpage needs to be carefully considered." (balloob, PR 28036) - FI-2 — Symmetric lifecycle across mixins/state/util (STRONG, extends FS4): every listener/observer/manager/timer a mixin or the state layer registers is attached only while needed and removed in
disconnectedCallback; everyhistory.pushStateis popped on all exit paths; app-global registration needs explicit justification. "we need to call this on disconnected too!" (wendevlin, PR 52165) - FI-3 — Design-token completeness (STRONG, extends F4/FS9): use
--ha-space-*(source:src/resources/theme/core.globals.ts),--ha-animation-base-duration(350ms / 0 under reduced motion — no manual reduced-motion guards on top), andvar(--safe-area-inset-*, 0px)with the mandatory0pxfallback inside thecalc()in framework chrome (sidebar, dialogs, subpages,src/resources/styles.ts). "please useha-space-tokens" (wendevlin, PR 28080) - FI-4 — Lokalise key lifecycle (STRONG, extends F9): a meaning/param change ⇒ a NEW key (Lokalise keeps serving the old one as unverified); wording-only iteration reuses the key; retired keys are deleted in Lokalise by maintainers — contributors just remove them from
en.json. Backend-owned strings arrive via the core Lokalise project (hass.loadBackendTranslation), never duplicated intoen.json. "When the translation has been changed in context … introduce a new translation" (silamon, PR 22021) - FI-5 — Web Awesome era (IRON candidate): new dialogs are
ha-wa-dialog;ha-wa-*wrappers extend the upstream Web Awesome class (full API reachable), import from@home-assistant/webawesome/dist/..., and expose only--ha-*semantic variables (mapped onto the--wa-*internals — never expose--wa-*/--mdc-*). Only place written down: the frontend repoAGENTS.mddecision table. "webawesome has a skeleton component. Use this please." (wendevlin, PR 29451) - FI-6 —
src/common/src/utilVitest coverage (STRONG, extends FS15): new/changed common/util logic ships with Vitest tests in the mirroredtest/path asserting observable behavior (length checks, edge inputs); inline-reimplementation tests are removed. wendevlin will not approve without them. "I would call the filesearch-highlightand I would like you to create unit tests" (wendevlin, PR 29401)
Verification
yarn lint (ESLint + Prettier + tsc + lit-analyzer), yarn lint:types, yarn test (Vitest). Keep yarn lint:types green — maintainers paste type errors verbatim as review comments.
References
For detailed patterns, see:
${CLAUDE_SKILL_DIR}/references/async-streams.md—@lit/task,repeat(), virtualizer, cache-backed startup paint${CLAUDE_SKILL_DIR}/references/forms-uploads.md—ha-formeditors, config validation,ha-file-upload${CLAUDE_SKILL_DIR}/references/components.md— presentational vs stateful components, reactive controllers,fireEvent${CLAUDE_SKILL_DIR}/references/pubsub-navigation.md— entity/collection subscriptions,navigate()routing${CLAUDE_SKILL_DIR}/references/js-interop.md— third-party non-Lit libraries (echarts, leaflet), cleanup, lazy-load${CLAUDE_SKILL_DIR}/references/channels-presence.md—hass.connectionsubscriptions,src/datacollection conventions, presence-style live state