Skip to content

Mock query

Simulated loading, failure and retry for demo data, keyed per component.

Status
Stable
Level
Utility
Category
Utilities
Adoption
Not used yet
import { useMockQuery } from "@oration/canon/hooks/use-mock-query";
packages/canon/src/hooks/use-mock-query.ts

Suppliers with open invoices

Loading
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 DataState with a skeleton that matches the layout.
  • To design an error state in place with fail: true, and its recovery with retry.
  • 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

Never gate a whole page on loading. Each component that shows data draws loading, empty and error at component level, with a skeleton that matches its final layout. Errors say what failed and offer Retry; they never blame the user.

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.

Simulate a timeout
Loading
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.

Supplier
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.

Loading
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#

States
StateTreatment
LoadingFrom mount until the wait elapses, and indefinitely while enabled is false. data is undefined.
Successdata is load(), re-read on every render. Warm keys and instant latency start here with no loading frame.
Errorerror 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.
Retryingretry() starts a new attempt: back to loading, then settles again.
EmptyNot 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: true fails every attempt, including retries.
  • Warm keys ignore the Simulate errors setting until loading is replayed, because a cached key never makes a new attempt.
  • data calls load() on every render once settled. Keep load cheap; reading a store inside it keeps live data current.
  • Timers are cleared on unmount, and changing the key, fail or enabled starts over.

Do and don't#

Do. Give each data set its own namespaced key: "ap.suppliers", "ap.remittances", "ap.w9.northwind".
Don't. Reuse a generic key such as "list". The second component inherits the first one's warm cache and never shows its own loading state.
Loading
Do. Pass the query to DataState with a skeleton shaped like the content.
Loading…
Don't. Branch on status by hand and show a centered spinner, with no error or empty state.
Do. Use fail: true while building, to design the error state where it will appear.
Don't. Ship a data view that has only ever been seen in its success state.

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. DataState builds 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. DataState sets aria-busy while 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.

Props of useMockQuery
PropTypeDefaultDescription
keyRequiredstringNo defaultIdentifies the data set. Drives the warm cache and the default delay.
loadRequired() => TNo defaultReturns the mock data. Called on every render once settled.
options.delaynumber250 to 850, stable per keyBase wait in ms, before the demo latency multiplier.
options.failbooleanfalseFail every attempt, to show the error state in place.
options.enabledbooleantrueWhile false the query stays in loading and never settles.

Returns

MockQuery<T>.

Props of Returns
PropTypeDefaultDescription
status"loading" | "error" | "success"No defaultWhere the simulated request is.
dataT | undefinedNo defaultload() while successful, otherwise undefined.
errorError | nullNo defaultThe timeout error while failed, otherwise null.
retry() => voidNo defaultStarts 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.