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 withSparkTimeoutError. 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, anySparkError). result.success === false. The use case ran but the integration returned an error;result.errorsays 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 promise7. 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).