List & Query States
@checkstack/ui ships a small family of primitives that cover the
recurring “loading / empty / error / responsive list” surfaces every
plugin frontend ends up reinventing. Reach for these before rolling
your own - they encode the project’s accessibility, performance, and
copy conventions in one place.
The current page sweeps that retrofit existing screens onto these primitives are tracked in Phases 5 - 7 of the v1 polishing plan.
ListEmptyState
Section titled “ListEmptyState”Thin wrapper around EmptyState for list-shaped resources. Supplies a
consistent “No {resource} yet” headline and an Inbox default icon so
callers don’t have to pick one for every list.
import { ListEmptyState, Button } from "@checkstack/ui";import { Plus } from "lucide-react";
<ListEmptyState resource="checks" description="Create a health check to start monitoring an endpoint." actions={ <Button> <Plus className="h-4 w-4 mr-2" /> Create your first check </Button> }/>;QueryErrorState
Section titled “QueryErrorState”Canonical inline error UI for a failed TanStack Query. Renders an
error-variant Alert with the message extracted via
extractErrorMessage from @checkstack/common, plus a Retry button
wired to onRetry (use the failing query’s refetch()).
import { QueryErrorState } from "@checkstack/ui";
const { data, error, refetch } = healthCheckClient.list.useQuery();
if (error) { return ( <QueryErrorState error={error} resource="checks" onRetry={() => refetch()} /> );}Skeleton
Section titled “Skeleton”Pulsing placeholder block for loading states. Honours
usePerformance().isLowPower: when low-power mode is active the pulse
animation is dropped and a static bg-muted block is rendered, so
non-hardware-accelerated devices aren’t forced through an infinite
animation loop.
import { Skeleton } from "@checkstack/ui";
<div className="space-y-2"> <Skeleton className="h-4 w-3/4" /> <Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-5/6" /></div>;Tables: use DataTable
Section titled “Tables: use DataTable”Every column table renders through the shared DataTable in
@checkstack/ui. It owns the responsive dual layout (a real <table>
on sm and up, stacked cards below when you pass renderMobileCard),
click-to-sort headers, an optional global search box, and an opaque
bg-card surface - so you no longer hand-compose Table primitives or
wire a separate mobile branch. See
Data tables for the
column contract and full API.
import { DataTable, type DataTableColumn } from "@checkstack/ui";
const columns: DataTableColumn<Row>[] = [ { id: "name", header: "Name", cell: (r) => <span className="font-medium">{r.name}</span>, sortValue: (r) => r.name, searchValue: (r) => r.name, }, { id: "status", header: "Status", cell: (r) => <Badge>{r.status}</Badge>, sortValue: (r) => r.status },];
<DataTable data={rows} columns={columns} getRowId={(r) => r.id} />;Don’t add columns to the mobile card that aren’t in the table, and
don’t reorder columns between the two. renderMobileCard is a
presentation of the same rows - same data, two layouts. Reuse the
per-row helpers (badges, action handlers, provenance locks) across
both branches so business rules can’t drift.
toastSuccess / toastError
Section titled “toastSuccess / toastError”Two named helpers in @checkstack/ui for the canonical post-mutation
toast shapes. toastSuccess is a verb-phrase passthrough;
toastError prefixes the action and funnels the error through
extractErrorMessage, truncating the final string to 100 characters.
import { useToast, toastSuccess, toastError } from "@checkstack/ui";
const toast = useToast();const { mutateAsync } = healthCheckClient.create.useMutation({ onSuccess: () => toastSuccess(toast, "Check created"), onError: (error) => toastError(toast, "Failed to create check", error),});There is intentionally no toast factory, DSL, or key-based template registry. If you need a domain-specific message just pass a string. Adding indirection here just spreads copy across files and obscures grep-ability.
Standard query-state pattern
Section titled “Standard query-state pattern”Every list page that drives its data from a single useQuery should
branch through the same four-state ladder: loading, error, empty,
data. Copy this snippet verbatim and only swap the resource noun and
the skeleton / list markup - the ordering and prop names are
load-bearing.
import { ListEmptyState, QueryErrorState, Skeleton,} from "@checkstack/ui";
const query = healthCheckClient.list.useQuery({});const items = query.data?.items ?? [];
return ( <> {query.isLoading ? ( <Skeleton className="h-32 w-full" /> ) : query.isError ? ( <QueryErrorState error={query.error} onRetry={() => { void query.refetch(); }} resource="health checks" /> ) : items.length === 0 ? ( <ListEmptyState resource="health checks" description="Create a check to start monitoring an endpoint." /> ) : ( <HealthCheckList configurations={items} /> )} </>);Notes:
- Skeletons should mimic the final layout. When the data path renders
a table, render 2-3 placeholder rows that match the column count
(
<Skeleton className="h-4 w-32" />inside<TableCell>works well) rather than a single generic block - the page should not jump when data resolves. onRetryis wrapped in an arrow that ignores the returned promise so the prop’s() => voidsignature is respected withoutvoidcall-site noise inside the JSX expression.- For detail pages where
useQueryreturns a single record, keep the existingif (!data) return nullearly-return and add a siblingif (isError)branch that rendersQueryErrorState- the ladder pattern is for list pages, not single-record loads.
Respecting low-power mode
Section titled “Respecting low-power mode”Decorative motion and blur effects should drop to a static state when
usePerformance().isLowPower is true. Use the hook directly with an
inline ternary (or cn for cleaner composition) - there is no helper
hook, the flag is the API.
import { cn, usePerformance } from "@checkstack/ui";
const { isLowPower } = usePerformance();
// Inline ternary form<Bell className={`h-5 w-5 ${isLowPower ? "" : "transition-transform group-hover:scale-110"}`}/>;
// `cn` form — preferred when there are several class fragments<div className={cn( "rounded-lg border bg-card shadow-sm", !isLowPower && "transition-all duration-200", )}/>;Apply this to: animate-* (except Skeleton’s own pulse, which is
already gated), backdrop-blur-*, hover:scale-*, decorative
transition-all / transition-transform / transition-opacity /
transition-shadow, and entry animations like animate-in fade-in.
Don’t wrap:
- Colour transitions (
transition-colors) - they’re cheap and don’t degrade UX on low-end devices. - Functional UX transitions - Drawer open/close, Dialog enter/exit,
and other Radix-driven animations are already centrally managed by
@checkstack/ui. Leave them alone. - Skeletons - the
Skeletonprimitive already drops its pulse in low-power mode. If you see rawanimate-pulseon loading placeholders it’s a candidate for migration toSkeleton, not for gating.
For backdrop-blur, swap to a solid background when low-power:
<div className={cn( "border border-border rounded-lg p-3 shadow-lg", isLowPower ? "bg-card" : "bg-card/90 backdrop-blur-sm", )}/>;Toast voice convention
Section titled “Toast voice convention”Use toastError(toast, action, error) for any error toast that pairs
an action prefix with an extracted error message. This standardises
the “Failed to X:
import { useToast, toastError } from "@checkstack/ui";
const toast = useToast();
const createMutation = client.createSecret.useMutation({ onSuccess: () => toast.success("Secret created"), onError: (error) => toastError(toast, "Failed to create secret", error),});
// `try`/`catch` flows work the same way.try { await updateConfigMutation.mutateAsync(payload); toast.success("Configuration saved");} catch (error) { toastError(toast, "Failed to save configuration", error);}Conventions:
- The action argument is a verb phrase ending without a colon -
toastErroradds the": "separator itself. - Leave terse one-liners like
toast.success("Saved")ortoast.error("Title is required")alone -toastSuccess/toastErrorexist for the multi-clause, error-bearing shape, not as a blanket replacement. - When you migrate every
extractErrorMessagecall in a file ontotoastError, drop the now-orphaned import - leaving it triggers the unused-import warning.