Build a block

Assemble session, entity data, use cases, dialogs, toasts, and localization into one working device control block.

Every SDK concept has its own page. This recipe puts them together in one realistic block: a device control card that reads its device from the page's entity context, shows live status from an Integration Hub use case, toggles power with an optimistic update, confirms a destructive reset in a host-rendered dialog, and reports every outcome as a portal toast.

The result is a single component, built up step by step. The domain is a wallbox, but nothing here is wallbox-specific: swap the entity slug and the use-case slugs and the same shape carries any block that reads one entity and controls something behind an integration.

1. Start from the session

The connection lifecycle is covered in Getting started: initialize() once in a provider, a spinner while the handshake is in flight, an error state if it fails, and a ResizeObserver reporting height so the iframe never scrolls. This recipe picks up from there. The app tree wraps the block in that session provider plus the i18n provider from step 7:

// app.tsx
import { DeviceBlock } from './device-block'
import { I18nProvider } from './providers/i18n-provider'
import { SparkSessionProvider } from './providers/spark-session-provider'

export function App() {
  return (
    <SparkSessionProvider>
      <I18nProvider>
        <DeviceBlock />
      </I18nProvider>
    </SparkSessionProvider>
  )
}

Two things are already handled at this point:

  • Theming. initialize() injects the portal's CSS variables and sets the light/dark class on <html>. Spark UI components read exactly those variables, so the block inherits the portal theme with no extra wiring. See Theming.
  • Standalone mode. Outside a portal there is no parent frame to answer the handshake, so initialize() rejects with SparkTimeoutError. The provider renders that as a handled state, never a crash.

One guest rule applies from the first line: no margin or padding on html, body, or the root element. The host owns all surrounding space; outer spacing renders as a visible seam next to other blocks.

2. Read the config and fetch the entity

session.config carries the block's per-installation options, merged by the portal with entityIds: the entity context of the page the block is embedded on. On a device detail page that is the device's asset id. Fetch the entity with getEntity and model the read as explicit states, so loading, failure, and data each have a render:

// device-block.tsx
import { getEntity, type EntityItem } from '@epilot/spark-sdk'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSparkSession } from './providers/spark-session-context'

type DeviceRead =
  | { kind: 'loading' }
  | { kind: 'error' }
  | { kind: 'ready'; device: EntityItem }

export function DeviceBlock() {
  const { t } = useTranslation()
  const session = useSparkSession()

  const integrationId = session.config?.integration_id as string | undefined
  const entityIds = (session.config?.entityIds ?? {}) as Record<string, string>
  const deviceId = entityIds.asset
  const isMockedMode = session.config?.is_mocked_mode === true

  const [read, setRead] = useState<DeviceRead>({ kind: 'loading' })

  useEffect(() => {
    if (!deviceId) {
      setRead({ kind: 'error' })
      return
    }
    let cancelled = false
    void (async () => {
      try {
        const device = await getEntity({
          slug: 'asset',
          entity_id: deviceId,
          fields: ['_id', '_title', 'asset_number'],
        })
        if (cancelled) return
        setRead(device ? { kind: 'ready', device } : { kind: 'error' })
      } catch {
        // SparkError / SparkNotInitializedError: same user-visible state
        if (!cancelled) setRead({ kind: 'error' })
      }
    })()
    return () => {
      cancelled = true
    }
  }, [deviceId])

  // status, actions, and rendering follow in the next steps
}

Note the split: the entity is epilot's own data (title, serial number). The device's live state lives behind the integration and is read through a use case in step 4. isMockedMode is a block-defined config option, not a portal-provided flag; it lets the block render and stay interactive in the block editor preview without firing real integration calls.

Larger blocks wrap these reads in TanStack Query instead of raw state (see the query pattern). Plain state keeps this recipe self-contained.

3. Render it with Spark UI

Three states, three renders: a CardSkeleton while loading, a Callout on failure, and a Card with Fields for the data. This sits at the bottom of the component, below the hooks; power, isSwitching, and the two handlers arrive in steps 4 and 5.

// device-block.tsx (render)
import { Badge } from '@epilot/spark-ui/badge'
import { Button } from '@epilot/spark-ui/button'
import { Callout, CalloutDescription } from '@epilot/spark-ui/callout'
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardIcon,
  CardSkeleton,
  CardTitle,
} from '@epilot/spark-ui/card'
import { Field, FieldLabel, FieldValue } from '@epilot/spark-ui/field'
import { MaterialSymbol } from '@epilot/spark-ui/icons/material-symbol'
import { Spinner } from '@epilot/spark-ui/spinner'

if (read.kind === 'loading') return <CardSkeleton />

if (read.kind === 'error') {
  return (
    <Callout variant="error">
      <CalloutDescription>{t('device.loadError')}</CalloutDescription>
    </Callout>
  )
}

const { device } = read

return (
  <Card>
    <CardHeader>
      <CardIcon>
        <MaterialSymbol className="text-[20px]" name="ev_station" />
      </CardIcon>
      <div>
        <CardTitle>{device._title}</CardTitle>
        <CardDescription>{t('device.subtitle')}</CardDescription>
      </div>
    </CardHeader>

    <CardContent>
      <div className="flex flex-col gap-3">
        <Field readOnly>
          <FieldLabel>{t('device.power')}</FieldLabel>
          <FieldValue>
            {power === null ? (
              t('device.statusUnknown')
            ) : (
              <Badge color={power ? 'success' : 'neutral'}>
                {power ? t('device.on') : t('device.off')}
              </Badge>
            )}
          </FieldValue>
        </Field>

        <Field readOnly>
          <FieldLabel>{t('device.serial')}</FieldLabel>
          <FieldValue>{String(device.asset_number ?? '')}</FieldValue>
        </Field>
      </div>
    </CardContent>

    <CardFooter>
      <Button
        styleVariant="soft"
        color="danger"
        size="small"
        disabled={power === null}
        onClick={() => void resetDevice()}
      >
        {t('device.reset.action')}
      </Button>
      <Button
        size="small"
        disabled={power === null || isSwitching}
        onClick={() => void togglePower()}
      >
        {isSwitching && <Spinner size="sm" />}
        {power ? t('device.turnOff') : t('device.turnOn')}
      </Button>
    </CardFooter>
  </Card>
)

The Card takes no variant, so it follows the portal's app-level card knob. For when to reach for the other card variants see Card; for how Field composes into editable forms see Field.

4. Toggle through the integration

The live status comes from executeUseCase, never from a direct API call: the portal proxies the request to Integration Hub, so the block holds no credentials. First the read:

// device-block.tsx (live status)
import { executeUseCase, toast } from '@epilot/spark-sdk'

const [power, setPower] = useState<boolean | null>(null)
const [isSwitching, setIsSwitching] = useState(false)

useEffect(() => {
  if (isMockedMode) {
    setPower(true) // editor preview renders sample state, no integration call
    return
  }
  if (!integrationId || !deviceId) return

  let cancelled = false
  void (async () => {
    try {
      const result = await executeUseCase({
        integration_id: integrationId,
        use_case_slug: 'get_device_status',
        payload: { deviceId },
      })
      if (!cancelled && result.success) {
        setPower(result.data?.on === true)
      }
    } catch {
      // rejected promise: the call itself failed; status stays unknown
    }
  })()
  return () => {
    cancelled = true
  }
}, [integrationId, deviceId, isMockedMode])

Then the mutation. The badge flips optimistically, rolls back on failure, and the button shows a pending state either way. In mock mode the optimistic flip is the whole effect: the guard returns before executeUseCase, so the block stays interactive in the block editor without reaching the integration.

// device-block.tsx (toggle handler)
async function togglePower() {
  if (power === null || isSwitching) return
  const next = !power

  setIsSwitching(true)
  setPower(next) // optimistic: the badge flips immediately

  try {
    if (!isMockedMode) {
      if (!integrationId) throw new Error('No integration configured')
      const result = await executeUseCase({
        integration_id: integrationId,
        use_case_slug: 'switch_device',
        payload: { deviceId, on: next },
      })
      if (!result.success) {
        throw new Error(result.error?.message ?? 'Switch failed')
      }
    }
    toast.success(next ? t('device.switchedOn') : t('device.switchedOff'))
  } catch {
    setPower(!next) // roll back the optimistic flip
    toast.error(t('device.switchFailed'))
  } finally {
    setIsSwitching(false)
  }
}

Failure has two shapes, and the try catches both (see Errors):

  • Rejected promise. The call itself failed: network, timeout, or the SDK not initialized (SparkTimeoutError, SparkNotInitializedError, any SparkError).
  • result.success === false. The use case ran but the integration returned an error; result.error says why. Throwing it funnels both shapes into one user-visible outcome: roll back and report.

The toast calls surface both outcomes to the user; step 6 covers why they render outside the iframe.

5. Confirm the destructive action in the portal

A reset is irreversible, so it needs a confirmation. Don't render a Dialog inside the block for this: a modal inside the iframe is clipped to the iframe, its backdrop covers only the block, and focus behaves wrong. openDialog asks the portal to render the dialog over the whole page instead. With destructive: true the host renders an alert dialog (role="alertdialog", no click-outside, warning-styled button).

// device-block.tsx (reset handler)
import { executeUseCase, openDialog, toast } from '@epilot/spark-sdk'

async function resetDevice() {
  const result = await openDialog('confirm', {
    title: t('device.reset.title'),
    body: t('device.reset.body'),
    confirmLabel: t('device.reset.confirm'),
    cancelLabel: t('common.cancel'),
    destructive: true,
  })
  if (!result?.confirmed) return

  try {
    if (!isMockedMode) {
      if (!integrationId) throw new Error('No integration configured')
      const res = await executeUseCase({
        integration_id: integrationId,
        use_case_slug: 'reset_device',
        payload: { deviceId },
      })
      if (!res.success) throw new Error(res.error?.message ?? 'Reset failed')
    }
    setPower(false)
    toast.success(t('device.reset.success'))
  } catch {
    toast.error(t('device.reset.error'))
  }
}

openDialog resolves { confirmed: boolean } when the user decides and null when they dismiss (Esc, click outside, cancel), so !result?.confirmed covers both paths with one check. Call it from an event handler, not a render effect: React StrictMode runs effects twice in development and would open the dialog twice.

6. Report the result

The same iframe rule applies to notifications: a toast rendered inside the block is clipped to the block and stacks in the wrong place. The SDK's toast sends the notification to the portal's own toaster, which renders it over the whole page. The API mirrors sonner, so the toast.success and toast.error calls in steps 4 and 5 are all there is to it.

For the reset, the three phases could also be driven from the promise itself. toast.promise awaits inside the block and upserts one toast through loading, success, and error:

const { unwrap } = toast.promise(runReset(), {
  loading: t('device.reset.pending'),
  success: t('device.reset.success'),
  error: t('device.reset.error'),
})

await unwrap() // settles like the original promise

7. Language

Every string above goes through t(), and that includes the strings handed to the host: confirmLabel and cancelLabel on the dialog, the toast titles. The host owns no translations; it displays what the block sends, verbatim. The same goes for aria-labels: no string is hardcoded in one language.

The wiring is the standard provider from Localization: set the initial language from session.lang, then subscribe to onLocaleUpdate so a language switch in the portal re-renders the block live, without an iframe reload.

// providers/i18n-provider.tsx (from the Localization page)
useEffect(() => {
  void i18n.changeLanguage(session.lang ?? 'en')
}, [session.lang])

useEffect(() => {
  return onLocaleUpdate((lang) => {
    void i18n.changeLanguage(lang)
  })
}, [])

8. Build and deploy

Build the block (vp build). The output is a static dist/ you can host anywhere; the portal loads it by the URL registered on the app component. The guest conventions from step 1 apply to the whole build: zero outer spacing, and styling only through Tailwind utilities and Spark tokens, never literal colors. Before handing off, run the three block checks: the seam test (no double spacing, no scrollbars, no off-brand colors at the iframe edge), the whitelabel test (inject a different theme with setThemeClass('dark') plus injectCSSVariables(...); the whole block follows, and anything that stays put is reading a literal color), and the guest-manners test (opened standalone, the block renders its handled timeout state instead of crashing).

On this page