Application patterns
Compose NAMCHE primitives around application-owned state and callbacks.
These recipes live outside the registry in
src/components/recipes/application-patterns.tsx.
They are type-checked with the site. They take plain values and async callbacks;
copy them into your application's feature directory, then adapt the data layer.
The source is also included below for agents.
1 projects
| Name |
|---|
| Expedition |
Data table, loading, empty and error
ProjectTable accepts a discriminated state union. Initial loading has a named
busy region, a live status and a decorative Skeleton. Failure offers Retry;
empty data uses Empty. A ready table keeps cached rows visible during refresh
and announces a background refresh failure without replacing the table.
Use TableCaption, column scopes and stable row IDs. Sorting belongs in a
labelled Button inside TableHead with aria-sort on the header; pagination
belongs in application state and the URL where appropriate.
Form, validation and submit
ProjectForm connects a FieldLabel, Input and FieldError using unique IDs.
Validation focuses the invalid control; network errors retain input. A pending
submit disables duplicate actions and announces progress. Replace its small
validation rule with the application's schema and map server field errors to
matching IDs. Keep credentials and raw server errors out of UI messages.
Dialog and async mutation
ProjectDialog names and describes the dialog and closes only after save
succeeds. Submit is not wrapped in DialogClose. On failure the form stays open.
Dismissal is blocked while saving so a second dialog cannot race the same
request. Give requests a timeout in the application so pending is bounded.
Base UI owns focus trapping and restoration. If success removes the trigger,
move focus to a surviving heading or row action in the application.
Destructive confirmation
DeleteProject uses AlertDialog, states the specific target and consequence,
and leaves the dialog open on failure. The action waits for the mutation;
Cancel and dismissal are blocked while deletion is in flight. After successful deletion, focus moves to the persistent result announcement.
The application must give requests a timeout so pending cannot trap the user indefinitely.
Do not optimistically remove irreversible data without a rollback plan.
List/detail layout
Use Sidebar for persistent application navigation and a semantic main for
the selected detail. A list of Item links works for selectable records;
Breadcrumb provides hierarchy. Keep selection in the router URL, use actual
anchors, label the navigation region and mark the current link with
aria-current="page". On narrow screens navigate to detail or use Sheet with
a title; do not hide the only route back. Retain the list's scroll position
when returning and move focus to the detail heading after navigation.
Settings screens
Group related fields with FieldSet and FieldLegend inside Card sections. Use Switch for immediate boolean settings, Checkbox for selections committed by Save, and Tabs only for local panels (route links for distinct URLs). Show unsaved changes as text. Disable Save while unchanged or pending, preserve dirty input on errors, and announce success. Destructive account actions belong in their own section with AlertDialog, not alongside routine Save.
Navigation and notifications
Use Sidebar, Breadcrumb, NavigationMenu or Pagination according to scope.
Use Button's render for links only when the action should look like a button;
normal navigation stays an anchor. Avoid wrapping interactive controls in links.
Mount one Sonner Toaster in the application shell. Show a toast after a user-triggered mutation succeeds; keep actionable failures inline with Retry. A toast must not be the only record of a failure. If a dialog is dismissed while saving, the application mutation callback should announce completion through the shell's live region or toast. Do not announce the same event both inline and through a toast while the form is still visible.
Skeletons and progress
Use Skeleton only for unknown content, hidden from assistive technology,
with one external loading announcement. Keep live status regions as siblings outside
all aria-busy containers, including enclosing dialogs, so progress is announced
while work is pending. Keep its shape close to the eventual
content to avoid layout shifts. Use Spinner for short actions and Progress
with a meaningful accessible label for measurable progress. Honour reduced
motion in the application; never convey pending only through animation.
Recipe source
'use client';
import { useId, useRef, useState, type FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Empty, EmptyHeader, EmptyTitle, EmptyContent } from '@/components/ui/empty';
import { Field, FieldLabel, FieldError } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '@/components/ui/alert-dialog';
export type Project = { id: string; name: string };
export type ProjectState =
| { status: 'loading' }
| { status: 'error'; retry: () => void }
| { status: 'ready'; projects: Project[]; refreshing?: boolean; refreshError?: boolean };
export function ProjectTable({ state }: { state: ProjectState }) {
if (state.status === 'loading') return (
<section aria-label="Projects">
<p role="status">Loading projects…</p>
<div aria-busy="true">
<Skeleton className="h-11 w-full" aria-hidden="true" />
</div>
</section>
);
if (state.status === 'error') return (
<Alert role="alert">
<AlertTitle>Projects could not be loaded</AlertTitle>
<AlertDescription className="text-foreground">Try again. Your data has not changed.</AlertDescription>
<Button variant="outline" onClick={state.retry}>Retry</Button>
</Alert>
);
return (
<section aria-label="Projects">
<p role="status">{state.refreshing ? 'Refreshing projects…' : state.refreshError ? 'Refresh failed. Showing previously loaded projects.' : `${state.projects.length} projects`}</p>
<div aria-busy={state.refreshing}>
{state.projects.length === 0 ? (
<Empty><EmptyHeader><EmptyTitle>No projects yet</EmptyTitle></EmptyHeader>
<EmptyContent>Create a project to get started.</EmptyContent></Empty>
) : (
<Table>
<TableCaption className="text-foreground">Projects in this workspace</TableCaption>
<TableHeader><TableRow><TableHead scope="col">Name</TableHead></TableRow></TableHeader>
<TableBody>{state.projects.map(project => <TableRow key={project.id}><TableCell>{project.name}</TableCell></TableRow>)}</TableBody>
</Table>
)}
</div>
</section>
);
}
export function ProjectForm({ save, onSaved, onPendingChange }: { save: (name: string) => Promise<void>; onSaved?: () => void; onPendingChange?: (pending: boolean) => void }) {
const id = useId();
const errorId = `${id}-error`;
const input = useRef<HTMLInputElement>(null);
const inFlight = useRef(false);
const [name, setName] = useState('');
const [pending, setPending] = useState(false);
const [invalid, setInvalid] = useState(false);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (inFlight.current) return;
setSaved(false);
if (!name.trim()) {
setInvalid(true);
setError('Enter a project name.');
input.current?.focus();
return;
}
inFlight.current = true;
setInvalid(false);
setPending(true);
onPendingChange?.(true);
setError('');
try { await save(name.trim()); setSaved(true); onSaved?.(); }
catch { setError('The project could not be saved. Your changes are still here; try again.'); }
finally { inFlight.current = false; setPending(false); onPendingChange?.(false); }
}
return (
<div className="grid gap-4">
<form onSubmit={submit} noValidate className="grid gap-4" aria-busy={pending}>
<Field data-invalid={invalid}>
<FieldLabel htmlFor={id}>Project name</FieldLabel>
<Input ref={input} id={id} aria-label="Project name" value={name} onChange={event => setName(event.target.value)} required disabled={pending} aria-invalid={invalid} aria-describedby={error ? errorId : undefined} />
{error && <FieldError id={errorId} className="text-foreground">{error}</FieldError>}
</Field>
<Button type="submit" disabled={pending}>{pending ? 'Saving…' : 'Save project'}</Button>
</form>
<p role="status">{pending ? 'Saving project…' : saved ? 'Project saved.' : ''}</p>
</div>
);
}
export function ProjectDialog({ save }: { save: (name: string) => Promise<void> }) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
return (
<Dialog open={open} onOpenChange={next => { if (!pending) setOpen(next); }}>
<DialogTrigger render={<Button>New project</Button>} />
<DialogContent showCloseButton={!pending}>
<DialogHeader><DialogTitle>New project</DialogTitle><DialogDescription className="text-foreground">Create a project in this workspace.</DialogDescription></DialogHeader>
<ProjectForm save={save} onPendingChange={setPending} onSaved={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
export function DeleteProject({ name, remove }: { name: string; remove: () => Promise<void> }) {
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
const [removed, setRemoved] = useState(false);
const result = useRef<HTMLParagraphElement>(null);
const inFlight = useRef(false);
async function confirm() {
if (inFlight.current) return;
inFlight.current = true;
setPending(true);
setError('');
try { await remove(); setOpen(false); setRemoved(true); }
catch { setError('Deletion failed. The project is still available. Try again.'); }
finally { inFlight.current = false; setPending(false); }
}
return (
<div>
<AlertDialog open={open} onOpenChange={next => { if (!pending) { setOpen(next); setError(''); } }}>
<AlertDialogTrigger render={<Button variant="outline" disabled={removed}>Delete {name}</Button>} />
<AlertDialogContent finalFocus={removed ? result : undefined}>
<AlertDialogHeader aria-busy={pending}>
<AlertDialogTitle>Delete {name}?</AlertDialogTitle>
<AlertDialogDescription className="text-foreground">This permanently removes the project. This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
{error && <p role="alert">{error}</p>}
<p role="status">{pending ? 'Deleting project…' : ''}</p>
<AlertDialogFooter aria-busy={pending}>
<AlertDialogCancel disabled={pending}>Keep project</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={pending} onClick={confirm}>Delete project</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p ref={result} tabIndex={-1} role="status">{removed ? `${name} deleted.` : ''}</p>
</div>
);
}
// Local fixture only: application-owned callbacks are the boundary to real APIs.
export function ApplicationPatternsDemo() {
const [fail, setFail] = useState(false);
const [tableState, setTableState] = useState<'loading' | 'empty' | 'error' | 'ready'>('ready');
const save = async () => {
await new Promise(resolve => setTimeout(resolve, 600));
if (fail) throw new Error('Example failure');
};
return (
<div className="grid w-full gap-4">
<Button variant="outline" onClick={() => setFail(!fail)} aria-pressed={fail}>Simulate failures: {fail ? 'on' : 'off'}</Button>
<div role="group" aria-label="Table example state" className="flex flex-wrap gap-2">
{(['loading', 'empty', 'error', 'ready'] as const).map(state => (
<Button key={state} variant="outline" aria-pressed={tableState === state} onClick={() => setTableState(state)}>{state}</Button>
))}
</div>
<ProjectTable state={tableState === 'loading' ? { status: 'loading' }
: tableState === 'error' ? { status: 'error', retry: () => setTableState('ready') }
: { status: 'ready', projects: tableState === 'empty' ? [] : [{ id: 'one', name: 'Expedition' }] }} />
<ProjectDialog save={save} />
<DeleteProject name="Expedition" remove={save} />
</div>
);
}