preact-homeassistant
v0.5.2
Published
Preact hooks and helpers for building Home Assistant custom cards
Maintainers
Readme
preact-homeassistant
Preact hooks and helpers for building Home Assistant custom cards. Handles the web-component lifecycle, Shadow DOM, entity subscriptions, and data fetching so you can focus on your card's UI.
Install
pnpm add preact preact-homeassistantpreact is a peer dependency.
Quick start
The easiest way is to use the template
import { registerPreactCard, HACard, useEntity, css } from 'preact-homeassistant';
css`
.my-card { padding: 16px; }
.my-card .temperature { font-size: 2em; }
`;
function MyCardContent({ config }: { config: { entity: string } }) {
const weather = useEntity(config.entity);
return (
<HACard>
<div class="card-content my-card">
<span class="temperature">{weather?.state ?? '...'}</span>
</div>
</HACard>
);
}
function MyCardEditor({ hass, config, onConfigChanged }) {
const entities = Object.keys(hass.states).filter((e) => e.startsWith('weather.'));
return (
<div style={{ padding: '16px' }}>
<ha-select
label="Weather entity"
value={config.entity}
naturalMenuWidth
fixedMenuPosition
onChange={(e) => onConfigChanged({ ...config, entity: (e.target as HTMLSelectElement).value })}
onclosed={(e) => e.stopPropagation()}
>
{entities.map((id) => (
<ha-list-item key={id} value={id}>
{hass.states[id]?.attributes?.friendly_name ?? id}
</ha-list-item>
))}
</ha-select>
</div>
);
}
registerPreactCard({
type: 'my-weather-card',
name: 'My Weather Card',
description: 'A simple weather card',
Component: MyCardContent,
ConfigComponent: MyCardEditor,
getStubConfig: () => ({ entity: '' }),
});That's it. registerPreactCard creates the web component, registers the custom
element with Home Assistant, sets up Shadow DOM, injects registered styles, and
wraps your component in the data provider. Your component receives config as a
prop and uses hooks for everything else.
registerPreactCard(options)
| Option | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | Custom element tag name (e.g. 'my-weather-card') |
| name | string | Yes | Display name in the HA card picker |
| description | string | Yes | Description in the HA card picker |
| Component | ComponentType<{ config: T }> | Yes | Main card Preact component |
| ConfigComponent | ComponentType<{ hass, config, onConfigChanged }> | No | Visual editor. Receives hass, the current config, and an onConfigChanged callback. Registered as ${type}-editor. |
| UnconfiguredComponent | ComponentType<{}> | No | Shown before config/hass are available |
| getStubConfig | () => Partial<T> | No | Default config for the card picker |
The card renders into a Shadow DOM root. The editor renders into the light DOM
(required for HA's own custom elements like <ha-select> to work).
<HACard>
Use HACard as the root of your card instead of a raw <ha-card>. It makes the
card fill the height Home Assistant assigns it.
In HA's sections (grid) layout, when a card is resized (e.g. to 3 rows) HA
gives the card's host element a definite height. A plain <ha-card> collapses to
its natural content height and renders slightly short, leaving a gap. HACard
sets the host and the ha-card to fill that height, so the card matches the slot
exactly. In layouts with no fixed height (masonry, auto rows) it safely collapses
back to natural height, so it's a drop-in replacement everywhere.
<HACard class="size-large" align="space-between">
<div class="card-content my-card">…</div>
</HACard>| Prop | Type | Default | Description |
|---|---|---|---|
| align | HACardAlign | 'top' | How content is distributed vertically when the slot is taller than the content. Friendly aliases top / center / bottom, or any flex justify-content value (space-between, space-around, space-evenly, flex-start, flex-end). |
| class | string | — | Class applied to the underlying ha-card. |
| children | ComponentChildren | — | Card contents. |
align only positions content as a block. To make an inner section stretch to
absorb the extra height, give it flex: 1 in your card's CSS.
Hooks
useEntity(entityId)
Subscribe to a specific entity. Only re-renders when that entity's state changes.
const sensor = useEntity('sensor.temperature');
// sensor?.state === '72'Returns a strict type based on the domain prefix:
'calendar.*'→CalendarEntity'weather.*'→WeatherEntity'sun.sun'→SunEntity'fan.*'→FanEntity- Other domains →
HassEntity(the loose type fromhome-assistant-js-websocket)
The mapping comes from the DomainEntityMap interface. To add a new domain,
see the Contributing types section below.
useService(entityId)
Returns a stable function that calls services on a specific entity. The
service domain is parsed from the entity ID prefix and entity_id is
auto-injected into every call. Service names and data shapes are
strongly typed via DomainServiceMap when the domain is registered.
const fanService = useService(config.entity); // config.entity: `fan.${string}`
await fanService('toggle'); // entity_id auto-injected
await fanService('set_percentage', { percentage: 67 });For registered domains (currently fan), TypeScript autocompletes service
names and validates the data shape. For other domains the hook still works,
just without per-service autocomplete — useful for ad-hoc calls until the
domain is added to DomainServiceMap.
The returned function is a no-op if the entity ID is empty (common while the
card config is being set up) or if hass isn't connected yet.
useHass()
Access the full hass object for reading config or making service calls that
useService doesn't cover (different entity per call, no entity, custom
return_response, etc.). Does not re-render on entity changes.
const { getHass } = useHass();
await getHass()?.callService('script', 'morning_routine');useHassValue(selector, isEqual?)
Subscribe to a derived slice of the hass object (config, themes, anything
that isn't entity state — entity state goes through useEntity). The selector
runs on every hass update, but the consumer only re-renders when isEqual
reports a change, so it's cheap for rarely-changing values. isEqual defaults
to Object.is.
const unitSystem = useHassValue((hass) => hass?.config?.unit_system?.temperature);useHassConfig()
Shorthand for useHassValue((hass) => hass?.config). Re-renders when
hass.config changes (units, latitude/longitude, etc.).
useDarkMode()
Shorthand for the active theme's dark-mode flag. Returns boolean.
useCalendarEvents(entityIds, { start, end })
Fetch events from one or more calendars for a date range. entityIds is an
array of `calendar.${string}` IDs, and every returned event carries the
calendarId it came from. Caches per-card in memory with
stale-while-revalidate, and debounce-refetches when any of the entities change.
const { events, status, error, refetch, prefetch } = useCalendarEvents(
['calendar.family', 'calendar.work'],
{ start, end },
);
// status: 'loading' | 'cached' | 'ready' | 'refreshing'
// Warm adjacent months without touching component state:
prefetch({ start: prevMonthStart, end: prevMonthEnd });prefetch(range) is best-effort — it skips ranges already cached and swallows
failures.
Events are read from the REST view GET /api/calendars/{entity_id}, which
(unlike the calendar.get_events service response) includes uid,
recurrence_id, and rrule. The service call is used as a fallback when
hass.callApi isn't available (test mocks, Storybook).
useWeatherForecast(entityId, type)
Fetch weather forecast data. Caches per-card in memory, debounce-refetches on entity changes, and auto-refetches at the top of each hour.
const { forecast, status, error, refetch } = useWeatherForecast('weather.home', 'hourly');useCachedFetch(cacheKey, fetcher, deps)
Generic hook for fetching data with per-card caching. The domain-specific hooks
above are built on this. fetcher is re-run whenever deps change; the result
is stored under cacheKey in the provider's cache.
const { data, status, error, refetch } = useCachedFetch(
`my-thing:${id}`,
() => fetchMyThing(id),
[id],
);Behavior is stale-while-revalidate and key-change aware: when cacheKey
changes it swaps to that key's cached value synchronously, or keeps the
previously rendered data on a cold key — it never blanks to a loading state.
'loading' only appears on a true cold start (nothing cached, nothing
fetched). In-flight fetches are ignored if a newer one has started.
useResizeObserver(ref, callback, deps?)
Observe an element's size via ResizeObserver. The callback fires once after
mount with the current size, on every subsequent resize, and whenever deps
change. The callback is held in a ref, so passing a fresh closure each render
is safe — the observer is never re-created.
const containerRef = useRef<HTMLDivElement>(null);
useResizeObserver(
containerRef,
({ width, height }) => {
if (width === 0 || height === 0) return; // optional, consumer's call
drawChart(canvasRef.current, forecast, width, height);
},
[forecast],
);The callback is suppressed while the element is detached from the document.
Zero width/height is passed through — many draw routines need to guard
against zero dimensions (a 0-sized canvas throws InvalidStateError on
drawImage; ratios of measurements like Math.ceil(width / cellSize)
produce Infinity when a dimension is zero and infinite-loop the next
for they feed into) — but the guard belongs at the call site so the hook
stays general-purpose.
Sizes are read from offsetWidth / offsetHeight (CSS pixels, includes
padding and border).
useWidth(ref)
Stateful sibling to useResizeObserver for the JSX path: tracks a
referenced element's width and re-renders the component when it changes.
Returns undefined until the first non-zero measurement, then a positive
number that never returns to undefined or 0 — transient zero-width
firings during HA layout transitions (dashboard switch, edit-mode toggle)
and detached states are silently ignored.
const ref = useRef<HTMLDivElement>(null);
const width = useWidth(ref);
return (
<div ref={ref}>
{width !== undefined && <Chart width={width} />}
</div>
);Use this when the width needs to appear in JSX (responsive layout, prop to
a sized child). For imperative use inside a draw callback, prefer
useResizeObserver directly — no state, no extra re-renders.
Styles
Styles are registered globally via the css\`tagged template and
auto-injected into each card's Shadow DOM byregisterPreactCard. Use
.styles.ts` files imported as side effects.
// MyCard.styles.ts
import { css } from 'preact-homeassistant';
css`
.my-card { padding: 16px; }
`;
// MyCard.tsx
import './MyCard.styles'; // registers styles on importregisterRawStyles(cssString)
Register a raw CSS string, e.g. from a Vite ?inline import. No-ops if the
exact string is already registered.
getAllStyles()
Returns every registered style as one concatenated string. registerPreactCard
calls this to inject styles into the card's Shadow DOM; you only need it if
you're rendering a card tree yourself.
Calendar mutations
Plain async functions (not hooks) for calendars that support writes, e.g. Local
Calendar. Each takes the hass object — get it from useHass(). They require
entity control permission, not admin, and WebSocket errors reject unchanged so
you can inspect err.code (e.g. 'unauthorized').
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from 'preact-homeassistant';
const { getHass } = useHass();
await createCalendarEvent(getHass(), 'calendar.family', {
dtstart: '2026-07-17T09:00:00',
dtend: '2026-07-17T10:00:00',
summary: 'Dentist',
});
await updateCalendarEvent(getHass(), 'calendar.family', uid, { ...event, summary: 'Dentist ✅' });
await deleteCalendarEvent(getHass(), 'calendar.family', uid, {
recurrenceId, // optional, for one instance of a recurring event
recurrenceRange, // optional, e.g. 'THISANDFUTURE'
});CalendarMutationEvent is the payload type: dtstart / dtend are either
date-only strings ('2026-07-17', all-day) or ISO datetimes, plus summary
and optional description, location, rrule.
Cache utilities
The fetch hooks cache into an in-memory Map owned by the provider, so its
lifetime matches the card and it is garbage-collected on teardown. There is
intentionally no persistence, TTL, or size cap: freshness comes from entity
subscriptions and periodic refetch rather than cache expiry. The read/write
helpers are internal — reach the cache through useCachedFetch.
Other utilities
useCallbackStable(fn)
Returns a stable callback ref that always calls the latest fn. Avoids effect
re-runs while keeping the closure current.
HAProvider
The context provider registerPreactCard wraps your card in. Export exists so
you can render a card outside Home Assistant (Storybook, tests) with a mock
hass.
<HAProvider hass={mockHass} subscribeToEntity={() => () => {}}>
<MyCardContent config={config} />
</HAProvider>| Prop | Type | Required | Description |
|---|---|---|---|
| hass | HomeAssistant \| undefined | Yes | The hass object handed to hooks. |
| subscribeToEntity | (entityId, cb) => () => void | Yes | Entity subscription plumbing. Return a no-op unsubscribe for static mocks. |
| subscribeToHass | (cb) => () => void | No | Notifies useHassValue consumers. Defaults to a no-op, so those hooks return their initial value and never update. |
| cache | Cache | No | Inject a cache Map to seed or inspect it. Defaults to a fresh per-provider Map. |
Types
All HA domain types live in src/types/:
calendar.ts—CalendarEntity,CalendarEvent,CalendarEventWithSourceweather.ts—WeatherEntity,WeatherForecast,ForecastTypesun.ts—SunEntityfan.ts—FanEntity,FanServicescommon.ts—HomeAssistant,FetchStatusindex.ts—DomainEntityMap,EntityForId<T>,DomainServiceMap,ServicesForId<T>
Re-exported from the package root:
import type {
HomeAssistant,
FetchStatus,
CalendarEntity,
CalendarEvent,
CalendarEventWithSource,
CalendarMutationEvent,
WeatherEntity,
WeatherForecast,
ForecastType,
SunEntity,
FanEntity,
FanServices,
EntityForId,
DomainEntityMap,
DomainServiceMap,
ServicesForId,
} from 'preact-homeassistant';The non-domain types HACardAlign, ElementSize, and ResizeCallback are
exported from the root as well, alongside their components/hooks.
Contributing types
The HA domain types in this package are intentionally minimal — only the domains the maintainers have actually needed. If your card needs strict types for another domain (light, climate, media_player, cover, etc.), PRs are very welcome.
- Look up the domain in the Home Assistant frontend repo — most domains have a
data/<domain>.tsfile with TypeScript types. - Add
src/types/<domain>.ts. Include an entity interface that extendsHassEntityBase/HassEntityAttributeBasefromhome-assistant-js-websocket, plus a services interface mapping each service name to its data shape (orundefinedfor services that take no payload beyondentity_id). Seesrc/types/fan.tsfor the shape. - In
src/types/index.ts, add the entity toDomainEntityMapand the services toDomainServiceMap, and re-export the new types. - Add a quick test under
src/__tests__/if you're feeling thorough. - PR.
Both the entity types and the service types are opt-in: until a domain
appears in DomainEntityMap, useEntity('light.foo') falls back to
HassEntity; until it appears in DomainServiceMap, useService('light.foo')
still works but without per-service autocomplete.
We err toward including only fields that are well-documented; speculative attributes can land later.
Development
pnpm install
pnpm test # vitest run
pnpm typecheck # tsc --noEmit
pnpm build # tsc --noEmit && vite build
pnpm lint # biome check src
pnpm lint:fix # biome check --write srcPublishing
Releases are published to npm manually from a local machine (no CI publish):
pnpm test && pnpm build
git tag v0.X.Y && git push origin v0.X.Y
pnpm publish --access public --provenanceThe --provenance flag attaches SLSA build attestation. A GitHub release with
release notes + the packaged tarball is created automatically when the tag is
pushed (see .github/workflows/release.yml).
License
MIT
