Skip to content

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.

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>
}
/>;

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()}
/>
);
}

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>;

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.

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.

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.
  • onRetry is wrapped in an arrow that ignores the returned promise so the prop’s () => void signature is respected without void call-site noise inside the JSX expression.
  • For detail pages where useQuery returns a single record, keep the existing if (!data) return null early-return and add a sibling if (isError) branch that renders QueryErrorState - the ladder pattern is for list pages, not single-record loads.

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 Skeleton primitive already drops its pulse in low-power mode. If you see raw animate-pulse on loading placeholders it’s a candidate for migration to Skeleton, 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",
)}
/>;

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: ” voice and centralises the 100-character truncation so verbose backend errors don’t blow out the toast surface.

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 - toastError adds the ": " separator itself.
  • Leave terse one-liners like toast.success("Saved") or toast.error("Title is required") alone - toastSuccess/toastError exist for the multi-clause, error-bearing shape, not as a blanket replacement.
  • When you migrate every extractErrorMessage call in a file onto toastError, drop the now-orphaned import - leaving it triggers the unused-import warning.