Mock query
Simulated loading, failure and retry for demo data, keyed per component.
Suppliers with open invoices
import { Button } from "@oration/canon/components/button";import { DataState } from "@oration/canon/components/data-state";import { SkeletonRows } from "@oration/canon/components/skeletons";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { replayLoading } from "@oration/canon/lib/demo-data";import { RotateCcwIcon } from "lucide-react";export function Hero() { const suppliers = [ { name: "Northwind Freight", terms: "Net 30", open: "$42,180.00" }, { name: "Halcyon Packaging", terms: "Net 45", open: "$9,615.50" }, { name: "Orchard Street Supply", terms: "Net 30", open: "$2,980.00" }, { name: "Bellweather Logistics", terms: "Net 60", open: "$11,040.75" }, ]; const query = useMockQuery("docs.ap.suppliers", () => suppliers, { delay: 900, }); return ( <div className="flex w-full max-w-md flex-col rounded-xl bg-card shadow-border"> <div className="flex items-center justify-between gap-3 py-2 pr-2 pl-4"> <h3 className="text-sm font-medium text-foreground"> Suppliers with open invoices </h3> <Button type="button" variant="ghost" size="sm" onClick={() => replayLoading()} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Replay loading </Button> </div> <DataState query={query} size="sm" errorTitle="Couldn't load suppliers" skeleton={ <SkeletonRows rows={4} avatar={false} lines={1} className="px-4 pb-2" /> } > {(list) => ( <ul className="flex flex-col px-2 pb-2"> {list.map((supplier) => ( <li key={supplier.name} className="flex h-9 items-center justify-between gap-3 px-2 text-13" > <span className="font-medium text-foreground"> {supplier.name} </span> <span className="text-muted-foreground tabular-nums"> {supplier.open} </span> </li> ))} </ul> )} </DataState> </div> );}Usage#
useMockQuery stands in for a network read over static mock data, so every data component owns real loading, error and retry states before there is a backend. It returns { status, data, error, retry }, which is exactly what DataState renders. Keys behave like a warm cache: once a key resolves it stays resolved for the session until replayLoading(). The usual mistakes are sharing one key between two data sets and hand-rolling the three states instead of passing the query to DataState.
When to use
- Any list, table, card or panel that reads mock data: pass the result to
DataStatewith a skeleton that matches the layout. - To design an error state in place with
fail: true, and its recovery withretry. - To hold a query in loading until a prerequisite exists, such as a selected supplier, with
enabled. - To give a heavy view a longer wait than the 250 to 850ms default with
delay.
When not to use
- Saving or other writes. Show progress on the control that started it. Use Pending button
- Tracking edits against a saved baseline. Use Dirty form
- Revealing model output over time. Use Simulated stream
- Changing latency or failures for the whole prototype. That's the demo settings, not a per-component option. Use Demo controls
- Local UI state such as open, selected or expanded. That's
useState, and it should never show a skeleton.
Every data component owns its states
Examples#
Failure and retry
fail: true fails every attempt, so the error state can be designed in place. Turn the switch off and the next attempt succeeds, on its own or through Retry.
import { DataState } from "@oration/canon/components/data-state";import { SkeletonRows } from "@oration/canon/components/skeletons";import { Switch } from "@oration/canon/components/switch";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import * as React from "react";export function FailureAndRetry() { const [fail, setFail] = React.useState(true); const remittances = [ { id: "RMT-4410", supplier: "Northwind Freight", amount: "$18,420.00" }, { id: "RMT-4411", supplier: "Halcyon Packaging", amount: "$6,215.50" }, ]; const query = useMockQuery("docs.ap.remittances", () => remittances, { fail, delay: 700, }); return ( <div className="flex w-full max-w-md flex-col gap-3"> <Switch label="Simulate a timeout" checked={fail} onCheckedChange={setFail} className="px-0" /> <div className="rounded-xl bg-card shadow-border"> <DataState query={query} size="sm" errorTitle="Couldn't load remittances" skeleton={ <SkeletonRows rows={2} avatar={false} lines={1} className="px-4 py-2" /> } > {(list) => ( <ul className="flex flex-col p-2"> {list.map((remittance) => ( <li key={remittance.id} className="flex h-9 items-center gap-3 px-2 text-13" > <span className="font-mono text-xs text-muted-foreground"> {remittance.id} </span> <span className="flex-1 font-medium text-foreground"> {remittance.supplier} </span> <span className="text-muted-foreground tabular-nums"> {remittance.amount} </span> </li> ))} </ul> )} </DataState> </div> </div> );}Reading the result
The raw return value. enabled holds the query in loading until a supplier is picked, and each supplier gets its own key, so picking one a second time is instant.
- status
- loading (waiting for a supplier)
- data
- undefined
- error
- null
import { Button } from "@oration/canon/components/button";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import { cn } from "@oration/canon/lib/utils";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function ReadingTheResult() { const suppliers = [ "Northwind Freight", "Halcyon Packaging", "Orchard Street", ]; const [supplier, setSupplier] = React.useState<string | null>(null); const query = useMockQuery( `docs.ap.w9.${supplier ?? "none"}`, () => ({ supplier, received: "Sep 14, 2026" }), { enabled: supplier !== null, delay: 800 }, ); return ( <div className="flex w-full max-w-md flex-col gap-4"> <fieldset className="flex min-w-0 flex-wrap gap-2"> <legend className="sr-only">Supplier</legend> {suppliers.map((name) => ( <Button key={name} type="button" variant="outline" size="sm" aria-pressed={supplier === name} className={cn(supplier === name && "bg-muted")} onClick={() => setSupplier(name)} > {name} </Button> ))} </fieldset> <dl className="grid grid-cols-[4.5rem_1fr] gap-x-3 gap-y-1.5 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13"> <dt className="font-mono text-xs leading-5 text-muted-foreground"> status </dt> <dd className="font-mono text-xs leading-5 text-foreground"> {query.status} {supplier === null ? " (waiting for a supplier)" : ""} </dd> <dt className="font-mono text-xs leading-5 text-muted-foreground"> data </dt> <dd className="text-foreground"> {query.data ? `W-9 from ${query.data.supplier}, received ${query.data.received}` : "undefined"} </dd> <dt className="font-mono text-xs leading-5 text-muted-foreground"> error </dt> <dd className="text-foreground"> {query.error?.message ?? "null"} </dd> </dl> <div> <Button type="button" variant="outline" size="sm" disabled={supplier === null} onClick={query.retry} > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Retry </Button> </div> </div> );}Empty result
Empty isn't a status of the hook. DataState shows it when isEmpty(data) is true, and because load is re-read every render, resolving the exceptions empties the list at once.
import { Button } from "@oration/canon/components/button";import { DataState } from "@oration/canon/components/data-state";import { SkeletonRows } from "@oration/canon/components/skeletons";import { toast } from "@oration/canon/components/toast";import { useMockQuery } from "@oration/canon/hooks/use-mock-query";import * as React from "react";export function EmptyResult() { const [resolved, setResolved] = React.useState(false); const exceptions = resolved ? [] : [ { id: "INV-20977", supplier: "Bellweather Logistics", reason: "Amount doesn't match the PO", }, { id: "INV-20981", supplier: "Orchard Street Supply", reason: "No goods receipt yet", }, ]; const query = useMockQuery("docs.ap.exceptions", () => exceptions); return ( <div className="w-full max-w-md rounded-xl bg-card shadow-border"> <DataState query={query} size="sm" errorTitle="Couldn't load exceptions" skeleton={ <SkeletonRows rows={2} avatar={false} className="px-4 py-2" /> } isEmpty={(list) => list.length === 0} empty={{ illustration: "done", title: "No exceptions in this run", description: "Every invoice matches its PO and goods receipt.", action: ( <Button type="button" variant="outline" size="sm" onClick={() => setResolved(false)} > Restore sample data </Button> ), }} > {(list) => ( <div className="flex flex-col"> <ul className="flex flex-col p-2"> {list.map((exception) => ( <li key={exception.id} className="flex flex-col gap-0.5 px-2 py-1.5" > <span className="text-13 font-medium text-foreground"> {exception.supplier} </span> <span className="text-xs text-muted-foreground"> <span className="font-mono"> {exception.id} </span>{" "} {exception.reason} </span> </li> ))} </ul> <div className="flex justify-end border-t border-border px-3 py-2"> <Button type="button" variant="outline" size="sm" onClick={() => { setResolved(true); toast.add({ type: "success", title: "2 exceptions resolved", }); }} > Resolve all </Button> </div> </div> )} </DataState> </div> );}States#
| State | Treatment |
|---|---|
| Loading | From mount until the wait elapses, and indefinitely while enabled is false. data is undefined. |
| Success | data is load(), re-read on every render. Warm keys and instant latency start here with no loading frame. |
| Error | error is Error("The request timed out."). Happens on every attempt with fail, or on the first attempt when the Simulate errors demo setting is on. |
| Retrying | retry() starts a new attempt: back to loading, then settles again. |
| Empty | Not a status of the hook. DataState shows it when isEmpty(data) is true. |
Behavior#
- The default wait is 250 to 850ms, derived from a hash of the key, so a given view always loads in the same time. It's multiplied by the demo latency: 0 for Instant, 1 for Realistic, 3 for Slow.
- With Instant latency and nothing failing, the status is success on the first render, so there is no skeleton flash.
- A key that resolved stays resolved for the session: remounting or navigating back renders data immediately.
replayLoading()forgets every key at once. - With the Simulate errors setting on, only the first attempt fails, so the error and its recovery can both be reviewed.
fail: truefails every attempt, including retries. - Warm keys ignore the Simulate errors setting until loading is replayed, because a cached key never makes a new attempt.
datacallsload()on every render once settled. Keeploadcheap; reading a store inside it keeps live data current.- Timers are cleared on unmount, and changing the key,
failorenabledstarts over.
Do and don't#
"ap.suppliers", "ap.remittances", "ap.w9.northwind"."list". The second component inherits the first one's warm cache and never shows its own loading state.DataState with a skeleton shaped like the content.status by hand and show a centered spinner, with no error or empty state.fail: true while building, to design the error state where it will appear.Content#
- Error titles name what failed: Couldn't load suppliers, not Error or Something went wrong.
- The description comes from the error and how to recover: The request timed out. Try again in a moment.
DataStatebuilds it for you. - Empty titles say what isn't there in this view (No exceptions in this run) and the description says why or what to do next.
- Keys are dotted, lowercase and scoped by area:
cc.callbacks,ap.payment-runs.
Accessibility#
- The hook draws nothing.
DataStatesetsaria-busywhile loading, adds a visually hidden Loading label, and announces errors through a polite status region. - Retry is a real button, so the recovery path works from the keyboard.
- Slow latency stretches every busy period; check that skeletons don't trap focus or shift the layout when they resolve.
API reference#
useMockQuery
useMockQuery<T>(key, load, options?) from @oration/canon/hooks/use-mock-query. Also exports the types MockQuery<T>, MockQueryStatus and MockQueryOptions.
| Prop | Type | Default | Description |
|---|---|---|---|
keyRequired | string | No default | Identifies the data set. Drives the warm cache and the default delay. |
loadRequired | () => T | No default | Returns the mock data. Called on every render once settled. |
options.delay | number | 250 to 850, stable per key | Base wait in ms, before the demo latency multiplier. |
options.fail | boolean | false | Fail every attempt, to show the error state in place. |
options.enabled | boolean | true | While false the query stays in loading and never settles. |
Returns
MockQuery<T>.
| Prop | Type | Default | Description |
|---|---|---|---|
status | "loading" | "error" | "success" | No default | Where the simulated request is. |
data | T | undefined | No default | load() while successful, otherwise undefined. |
error | Error | null | No default | The timeout error while failed, otherwise null. |
retry | () => void | No default | Starts a new attempt. Stable across renders. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Every failure is the same timeout error, so error copy for other cases (no permission, not found, rate limited) can't be exercised through the hook.
Turning on Simulate errors has no visible effect on warm data until someone also runs Replay loading states, which reads as a broken switch.