Docs: states[k]?.state returns undefined for missing entities — Jinja vs JS asymmetry can silently flip aggregation logic
### What this is
A documentation/awareness request, not a code bug. Posting here to help the next person who hits the same subtle bug pattern when porting aggregation logic between Jinja templates and `button-card` JS.
### Context
When you read an entity state inside a button-card JS template via `states['my_entity']?.state`, the value is **`undefined`** if the entity isn't loaded in HA at all (entity disabled, integration removed, never created, etc.). This is correct JS optional-chaining behavior.
For the **same** missing-entity case, the equivalent Jinja accessor (`states('my_entity')`) returns the **literal string `'unknown'`**.
The asymmetry silently flips the semantics of a common defensive pattern that uses an inclusion check to "skip missing entities":
```js
// button-card JS — looks defensive, actually BROKEN when entity is missing
const keys = ['binary_sensor.foo', 'binary_sensor.bar'];
let present = 0, ok = 0;
for (const k of keys) {
const st = states[k]?.state;
if (!['unknown', 'unavailable', 'none', ''].includes(st)) {
present++;
if (st === 'on') ok++;
}
}
```
```jinja
{# Jinja — works correctly when entity is missing #}
{%- set keys = ['binary_sensor.foo', 'binary_sensor.bar'] -%}
{%- set ns = namespace(present=0, ok=0) -%}
{%- for k in keys -%}
{%- set st = states(k) -%}
{%- if st not in ['unknown', 'unavailable', 'none', ''] -%}
{%- set ns.present = ns.present + 1 -%}
{%- if st == 'on' -%}{%- set ns.ok = ns.ok + 1 -%}{%- endif -%}
{%- endif -%}
{%- endfor -%}
```
Trace, JS version:
- `states['binary_sensor.foo']` is `undefined` (entity not loaded)
- `undefined?.state` is `undefined`
- `!['unknown', 'unavailable', 'none', ''].includes(undefined)` is `!false` is `true`
- So the loop body executes, `present++`, and the missing entity is counted as **present-but-not-OK** — exactly the case the inclusion check was meant to filter out.
Jinja version: `states('binary_sensor.foo')` returns the string `'unknown'`, which IS in the inclusion list, so it gets correctly skipped.
### Reproduction
Build a multi-entity device_tile that aggregates 2+ entities using the JS pattern above. Delete or disable one of the referenced integrations. The tile color/state will reflect "1/2 OK" (amber) instead of "1/1 OK" (green) — even though the second entity has simply ceased to exist.
(Concrete example I hit: aggregating Tailscale node `key_expiry_disabled` binary_sensors. When one of the named nodes wasn't actually paired in HA, the JS tile read amber while the Jinja-equivalent tile on a different dashboard read green.)
### Fix in user code
Add the `undefined` case to the skip:
```js
for (const k of keys) {
const s = states[k];
if (!s) continue;
const st = s.state;
if (['unknown', 'unavailable', 'none', ''].includes(st)) continue;
present++;
if (st === 'on') ok++;
}
```
### Suggested documentation update
The button-card README's "Templates" section already notes JS templates have access to the full `states` object. Adding a brief note about the `undefined` return for missing entities (vs Jinja's `'unknown'` string) would save the next person a debugging round.
Happy to PR a docs update if it'd be welcome — let me know the preferred section/wording.
### Environment
- button-card: latest from HACS (custom_components/button-card)
- Home Assistant Core: 2026.4.4
- Hit during a multi-dashboard refactor; the fix shipped as `if (!s) continue;` across all aggregating tiles in our setup.
关闭于 2026-05-11 2 条评论