i99dash docs
Recipes

Cabin dashboard with i99dash/react

A four-tile dashboard that combines car status, climate, AQI, and now-playing — using i99dash/react hooks. Shows the per-family fallback pattern + how `client.has` lets you hide tiles on hosts that don't ship a family.

A real "everything but the kitchen sink" tile layout — four read-only families on one screen, each gracefully degrading on hosts that don't ship them. The full source is < 150 lines.

┌──────────────────────┬──────────────────────┐
│  🚗  88%   🔒        │  🌡  21°C → 22°C    │
│  Tesla S · parked    │  AC · auto · 40%     │
├──────────────────────┼──────────────────────┤
│  💨  AQI 42 · pm 12  │  🎵  Track / Artist  │
│  ambient 800 lux     │  bluetooth · 40%     │
└──────────────────────┴──────────────────────┘

Prerequisites

  • A scaffolded React mini-app (Next.js / Vite / CRA — anything).
  • i99dash ≥ 0.3 and i99dash/react ≥ 0.1.

Manifest

{
  "id": "cabin_dashboard",
  "name": { "en": "Cabin Dashboard" },
  "version": "1.0.0",
  "url": "https://miniapps.i99dash.app/cabin/",
  "icon": "./assets/icon.svg",
  "category": "vehicle"
}

App root — provider + capability check

// app/page.tsx (Next.js App Router)
'use client';

import { useEffect, useState } from 'react';
import { MiniAppProvider } from 'i99dash/react';
import { createClientOrSSR, type MiniAppClient } from 'i99dash';
import { Dashboard } from './Dashboard';

export default function Page() {
  // Start null on both server and client first paint — matches the
  // SSR tree, then the effect produces a single real client on mount.
  const [client, setClient] = useState<MiniAppClient | null>(null);
  useEffect(() => setClient(createClientOrSSR()), []);
  return (
    <MiniAppProvider client={client}>
      <Dashboard />
    </MiniAppProvider>
  );
}

Capability-aware layout

The dashboard hides tiles for families the host doesn't ship. Compute the capability set once at mount; render conditionally.

// app/Dashboard.tsx
'use client';

import { useEffect, useState } from 'react';
import { useClient } from 'i99dash/react';
import { CarTile } from './tiles/CarTile';
import { ClimateTile } from './tiles/ClimateTile';
import { EnvironmentTile } from './tiles/EnvironmentTile';
import { MediaTile } from './tiles/MediaTile';

export function Dashboard() {
  const client = useClient();
  const [caps, setCaps] = useState<Set<string>>(new Set());

  useEffect(() => {
    if (!client) return;
    client.capabilities().then((c) => setCaps(new Set(c.families)));
  }, [client]);

  return (
    <div className="grid">
      {/* `car.status` (no `.read`) is the legacy family ID the host
          reports back — distinct from the manifest scope `car.status.read`.
          Other families use the same string for both. */}
      {caps.has('car.status') && <CarTile />}
      {caps.has('climate.read') && <ClimateTile />}
      {caps.has('vehicle.environment') && <EnvironmentTile />}
      {caps.has('media.read') && <MediaTile />}
    </div>
  );
}

Tiles — one per family

Each tile owns its own subscription via the matching hook. Hooks return { data, error }; the fallback keeps the layout stable during SSR / before the first event.

Car

import { useCarStatus } from 'i99dash/react';
import type { CarStatus } from 'i99dash';

const fallback = {
  deviceId: '',
  brand: 'byd',
  at: '1970-01-01T00:00:00.000Z',
  staleness: 'very_stale',
  batteryPct: 0,
  doorsLocked: true,
  isMoving: false,
} satisfies CarStatus;

export function CarTile() {
  const { data } = useCarStatus({ fallback });
  return (
    <div className="tile">
      <div className="row">
        <span>🚗 {data?.batteryPct}%</span>
        <span>{data?.doorsLocked ? '🔒' : '🔓'}</span>
      </div>
      <small>{data?.isMoving ? 'driving' : 'parked'}</small>
    </div>
  );
}

Climate

import { useClimate } from 'i99dash/react';

export function ClimateTile() {
  const { data } = useClimate();
  if (!data) return <div className="tile tile--idle">…</div>;
  return (
    <div className="tile">
      <div className="row">
        🌡 {data.cabinTempC.toFixed(0)}°C → {data.setpointC.toFixed(0)}°C
      </div>
      <small>
        {data.mode === 'off' ? 'off' : `AC · ${data.mode} · ${Math.round(data.fanSpeed * 100)}%`}
      </small>
    </div>
  );
}

Environment

import { useVehicleEnvironment } from 'i99dash/react';

export function EnvironmentTile() {
  const { data } = useVehicleEnvironment();
  if (!data) return <div className="tile tile--idle">…</div>;
  return (
    <div className="tile">
      <div className="row">
        💨 AQI {data.aqi ?? '—'} · pm {data.pm25 ?? '—'}
      </div>
      <small>ambient {data.ambientLightLux ?? '—'} lux</small>
    </div>
  );
}

Media

import { useMedia } from 'i99dash/react';

export function MediaTile() {
  const { data } = useMedia();
  if (!data || data.source === 'none')
    return <div className="tile tile--idle">— silence —</div>;
  return (
    <div className="tile">
      <div className="row">🎵 {data.title ?? '(untitled)'} / {data.artist ?? ''}</div>
      <small>
        {data.source} · {Math.round(data.volume * 100)}%
      </small>
    </div>
  );
}

What you get for free

  • One bridge subscription per tile, refcounted. N hooks rendering the same family share the underlying sub.
  • Page Visibility pause + catch-up. Backgrounded tab costs near-zero CPU; first visible frame has the latest payload.
  • Per-family throttle — host bucket sizes are family-specific (e.g. media 15 / 33 ms for volume scrubbing, climate 4 / 250 ms for setpoint changes). Latest event always wins.
  • Schema validation. Malformed payloads drop with a dev console.warn; your render never crashes on a host bug.
  • SSR-safe. Server renders the fallback; client re-renders with real data after hydration.

Adding a new family later

If you want to add system.read (units / brightness) or connectivity.read (network type) later, render a tile that uses useSystem() / useConnectivity(). No extra wiring. Each family is structurally identical from the React side.

On this page