@col/page-states
Versie: 1.0.0 Toegevoegd: 2026-08-06
Herkomst
Vier apps schreven elk hun eigen laden/leeg/fout-drieluik. growerportal2/src/components/ui/error-state.tsx
(22 regels, gebruikt in 7 content-componenten) plus de .empty-state/.page-header/.filter-bar
CSS-recepten in src/app/globals.css leverden de icoon-in-cirkel-vorm. floriday api/src/app/(protected)/aanbod/
is het meest doordachte van de vier: empty-state.tsx benoemt wélke actieve filters tot nul rijen
leidden en biedt per filter een link om hem te wissen, en waking-up-notice.tsx verschijnt pas na
drie seconden zodat hij niet knippert bij een snelle laadbeurt (Neon suspendeert na vijf minuten
stilte, en de eerste query daarna duurt merkbaar). supplier-onboarding-vercel/src/app/error.tsx,
not-found.tsx en global-error.tsx zijn drie keer met de hand geschreven volledige-pagina-schermen
met logo en een groot statusnummer. voorraadbeheer/src/app/(dashboard)/products/page.tsx (en
zeven-plus andere paginas) inlinen de hele triade letterlijk: een rode foutbox, platte laadtekst,
een leeg-blok met dashed border en een lucide-icoon.
Dit item combineert drie van de vier: groerportal2's icoon-in-cirkel-vorm, floriday's hints-vondst en zijn vertraagde melding, en voorraadbeheer's dashed-border-kader. Het vierde patroon — de volledige-pagina-schermen van supplier-onboarding-vercel — zit er bewust niet in; zie Bekende beperkingen.
Waarvoor
Vier losse componenten voor een blok ín een pagina: aan het laden, leeg, of mislukt. Pak
LoadingState voor een generieke spinner met optioneel onderschrift, EmptyState voor een
nul-resultaten-blok (met of zonder filter-hints), ErrorState voor een mislukte fetch met
optionele retry-knop, en WakingUpNotice los binnen een loading.tsx/Suspense-fallback voor een
serverloze database die koud kan opstarten.
Gebruik het niet voor een paginaspecifieke skeleton (die moet de vorm van de content kennen — zie Bewuste keuzes) en niet voor het volledige 404/500-scherm met logo en statusnummer — dat is een ander soort ding, zie Bekende beperkingen.
Wat je app moet leveren
Niets structureels — vier presentatiecomponenten, alle tekst via props. Geen backend-contract, geen hooks.
import { LoadingState, EmptyState, ErrorState, WakingUpNotice } from '@/components/page-states'
<LoadingState label="Aanbod laden..." />
<EmptyState
icon={Package}
title="Geen aanbodregels gevonden"
description={`Periode ${formatRange(filters.range)}, locatie ${filters.locations.join(', ')}.`}
hints={[
// href: een echt anker (prefetch, delen, middelklik) — voor URL-gedreven filters zoals hier.
{ label: 'Wis alle filters', href: buildHref({ ...filters, locations: [], search: '' }, view) },
// geen href: een knop — voor client-side gefilterde state die niet in de URL zit.
{ label: 'Wis zoekterm "roos"', onClear: () => setSearch('') },
]}
action={<Button onClick={createNew}>Nieuw product</Button>}
/>
<ErrorState
title="Het aanbod kon niet worden geladen"
description="Er ging iets mis bij het ophalen van de gegevens."
retryLabel={<><RefreshCw className="mr-2 h-4 w-4" />Opnieuw proberen</>}
onRetry={() => router.refresh()}
/>
// binnen loading.tsx / een Suspense-fallback
<WakingUpNotice>De database wordt waarschijnlijk wakker na een stille periode.</WakingUpNotice>
function LoadingState(props: { label?: string }): JSX.Element
type EmptyStateHint =
| { label: string; href: string; onClear?: () => void } // anker; onClear vuurt als zijkanaal op onClick
| { label: string; href?: undefined; onClear: () => void } // knop; onClear is de enige actie
function EmptyState(props: {
icon?: React.ComponentType<{ className?: string }>
title: string
description?: string
action?: React.ReactNode
hints?: EmptyStateHint[]
}): JSX.Element
function ErrorState(props: {
title?: string // standaard "Something went wrong"
description?: string
onRetry?: () => void // weglaten = geen retry-knop
retryLabel?: React.ReactNode // standaard "Retry"; ReactNode zodat een icoon + tekst kan
}): JSX.Element
function WakingUpNotice(props: {
afterMs?: number // standaard 3000
children?: React.ReactNode // weglaten = standaardtekst in het Engels
}): JSX.Element
| Component | Verplicht | Opmerking |
|---|---|---|
LoadingState |
— | label weglaten toont alleen de spinner |
EmptyState |
title |
icon is een componentverwijzing (icon={Package}), geen vooraf gerenderd element — zie Bewuste keuzes. Elke hints-entry heeft óf href (rendert als anker) óf onClear zonder href (rendert als knop) — nooit geen van beide, dat sluit de discriminated union uit |
ErrorState |
— | onRetry weglaten laat de knop weg, niet alleen uitschakelen. retryLabel mag een icoon bevatten (ReactNode) |
WakingUpNotice |
— | moet binnen een loading.tsx/Suspense-fallback staan; de timer start bij mount |
Bestanden
| Bestand | Landt in | Soort |
|---|---|---|
page-states.tsx |
components/page-states.tsx |
beheerd |
registryDependencies: button (voor de retry-knop, de hint-knoppen zónder href, en de
buttonVariants-styling van de hint-ankers mét href; geen asChild, gewoon een directe
<Button onClick> resp. een <Link className={cn(buttonVariants({...}))}> — zie AGENTS.md regel
5, hetzelfde patroon, geen nieuw idioom). Geen cssVars.
Dit item importeert next/link, en is daarmee Next-specifiek — een bewuste keuze, geen
bijvangst. Alle vijf de bronapps draaien Next 16, en @col/auth-pages-starter importeert next/link
al (login-page.tsx, forgot-password-page.tsx, reset-password-page.tsx), dus dit item introduceert
geen nieuwe afhankelijkheid in de registry, alleen een nieuwe voor dít item zelf. Een consument buiten
Next.js zou href-hints niet kunnen installeren zonder de import te vervangen door zijn eigen
routerlink.
Gebruikt door
| App | Sinds versie | Opmerkingen |
|---|---|---|
| — | Nog niet vanuit dit item geïnstalleerd. |
growerportal2 (7 content-componenten + CSS-recepten), floriday api ((protected)/aanbod/) en
voorraadbeheer (products, orders en meer) hebben elk hun eigen versie — zie Herkomst.
supplier-onboarding-vercel's drie volledige-pagina-schermen blijven buiten dit item, zie
Bekende beperkingen. Voeg hier een regel toe zodra een app is overgezet.
Bewuste keuzes
LoadingStateis een spinner, geen skeleton. floriday'sloading.tsxtoont een skeleton-grid die exact de vorm van de uiteindelijke tabel volgt (titel, filterbalk, tien rijen). Een generiek component kan die vorm niet kennen zonder een eigen layout-prop-taal te verzinnen die net zo veel uitlegt als de skeleton-JSX zelf — op dat punt is het geen herbruikbaar component meer maar een tweede manier om dezelfde pagina te beschrijven.growerportal2envoorraadbeheergebruiken allebei al een spinner/platte laadtekst, dus dat is de vorm die dit item wél generiek kan geven. Gevolg, expliciet benoemd: floriday'sloading.tsx-skeleton is metLoadingStateniet te bouwen — dat blijft bewust bespoke naast dit item. Wat wél overkomt uit diezelfde pagina isWakingUpNotice, die floriday al als los component had (waking-up-notice.tsx) en die geen paginakennis nodig heeft.iconis een componentverwijzing (React.ComponentType<{ className?: string }>), geenReactNode.growerportal2rendert zijn icoon bloot (<RiShipLine />) binnen een.empty-state-icon-wrapper die de grootte via een CSS-selector (.empty-state-icon svg) afdwingt;voorraadbeheerrendert het zelf vooraf gestyled (<Package className="h-12 w-12 text-gray-300" />). Een componentverwijzing is de vriendelijkere vorm voor de aanroeper:icon={Package}volstaat,EmptyStatezet het zelf in een cirkel op een vaste grootte, en elkeEmptyStatein elke app ziet er hetzelfde uit zonder dat iemand Tailwind-classes hoeft te onthouden. Dat is bewust anders danUserMenuItem.iconelders in dit design system (ReactNode) — dat icoon staat inline naast tekst op de grootte die de aanroeper daar toch al gebruikt, dit icoon zit in een vaste cirkel die het component zelf bepaalt. De prijs:voorraadbeheer's exacte grootte (h-12, ongekaderd) komt er bij migratie niet letterlijk uit — het component normaliseert naar zijn eigen cirkel-en-maat-recept. Zowel@remixicon/reactalslucide-reactexporteren componenten die eenclassName-prop accepteren, dus beide bronbibliotheken passen zonder aanpassing.EmptyStateheeft een dashed border, altijd.floriday's eigenhints-blok gebruikt alrounded-lg border border-dashed p-6— exact het kader dat dit item overneemt — envoorraadbeheerdoet hetzelfde.growerportal2is de uitzondering (kaal, binnen een sowieso al omkaderdeCardof tabelcel); bij migratie verdwijnt daar de buitensteCard/rij-wrapper zodat er geen dubbele rand ontstaat.hintsis een discriminated union:hrefgeeft een echt anker, geenhrefgeeft een knop. De eerste versie van dit item maaktehintseen kale knop metonClear, met als argument dat niet elke consument zijn filters in de URL bewaart. Dat argument staat nog steeds voor de knop-variant (een client-side gefilterde tabel zoalsvoorraadbeheer's producten-pagina heeft geen URL om naar te linken) — maar het loste het verkeerde probleem op voor floriday zelf, de bronapp waarhintsís gemodelleerd naar. floriday's eigen versie is een<Link href={...}>, enrouter.push(href)in eenonClear-callback herstelt de URL ná de klik, niet wat er vóór de klik al bestaat: prefetch bij hover, rechtsklik-link-kopiëren, middelklik-in-nieuw-tabblad. Op een lijstpagina waar de URL het hele statusmodel is, is dat geen cosmetisch verlies. Vandaar de union:{ label; href: string; onClear?: () => void } | { label; href?: undefined; onClear: () => void }. Methrefrendert het component een<Link>, gestyled metcn(buttonVariants({ variant: 'outline', size: 'sm' }))— hetzelfde patroon als AGENTS.md regel 5 voorschrijft voor een Radix-trigger, hier toegepast op een gewoon anker: geenasChild, direct de classes. Een meegegevenonClearvuurt dan alsonClick, als zijkanaal — het blokkeert of vervangt de navigatie niet. Zonderhrefverandert er niets aan de knop-variant. "Geen van beide" is door de union onmogelijk gemaakt, dus geen runtime-guard en geen stille no-op-knop.retryLabelisReact.ReactNode, nietstring. growerportal2'sErrorStaterendert een icoon vóór de tekst op zijn retry-knop (<RiRefreshLine className="mr-2 h-4 w-4" />+ "Probeer opnieuw"). MetretryLabel: stringwas dat niet uit te drukken — het enige stuk van growerportal2 dat de eerste versie van dit item niet kon overnemen.ReactNodelost het op zonder gedragsverandering voor een aanroeper die gewoon een string meegeeft.title/retryLabelhebben een Engelse standaardtekst,EmptyState.titleniet. Dat lijkt inconsistent maar is bewust:ErrorStatekan zinvol zonder enige prop renderen (een generieke "Something went wrong" is altijd waar),EmptyState.titleniet — "geen resultaten" is nooit automatisch waar voor een willekeurige lijst, dus die prop is verplicht in plaats van een gok te wagen. Beide standaardteksten zijn gewone default-parameterwaarden (zoalsalign = 'end'bijUserMenuelders in dit design system), geen vertaalsysteem — een aanroeper met een eigen taal geeft zijn eigen string (of, voorretryLabel, eigen node) mee en de standaardtekst wordt nooit zichtbaar.onRetryweglaten verbergt de knop, in plaats van hem uit te schakelen. Een disabled retry-knop zou suggereren dat opnieuw proberen ooit weer kan — voor een plek waar dat niet zinvol is (bv. na een 403), is geen knop eerlijker dan een grijze.WakingUpNotice's timer start bij mount, niet bij een explicietestart-prop. Dat dwingt het gebruikspatroon af dat floriday al had: het component leeft binnenloading.tsx, en de tijd dat het gemount is, is precies de tijd dat de fetch nog loopt. Eenvisible/start-prop zou de aanroeper de mogelijkheid geven de klok te laten lopen terwijl er allang data is, en dan verschijnt de melding op een moment dat hij niet meer klopt.
Bekende beperkingen
- Geen volledige-pagina 404/500-scherm.
supplier-onboarding-vercel'serror.tsx,not-found.tsxenglobal-error.tsxtonen een logo, een groot statusnummer (404/500) en vullen het hele scherm — een ander soort ding dan een blok ín een pagina, en de enige van de vier bronapps die dit patroon heeft. Dat is precies waarom het hier niet in zit (ziedocs/specs/2026-08-06-v10-basisitems.md, sectie 3: "Wat er niet in gaat"). Kandidaat voor een eigen, later item — het zou het merklogo nodig hebben (@col/brand-logo) en is daarmee sowieso geen-starter-loze toevoeging aan dít item. floriday's paginaspecifieke skeleton-grid is niet te bouwen metLoadingState. Zie Bewuste keuzes hierboven — bewust, niet vergeten.- Dit item is Next-specifiek (
next/linkvoorhintsmethref) — geen probleem voor de vijf bronapps (allemaal Next 16, en@col/auth-pages-starterimporteertnext/linkal), maar wel een grens: een consument buiten Next.js kanhref-hints niet installeren zonder de import zelf te vervangen. Zie Bewuste keuzes en Bestanden. - Geen ingebouwde
console.error/logging bijErrorState. floriday'serror.tsxlogt de onderliggende fout in eenuseEffectvoordat het de melding toont ("de onderliggende fout gaat naar de serverlog, niet naar het scherm"). Dat blijft de verantwoordelijkheid van de aanroeper (meestal de route-levelerror.tsxzelf, vóór het renderen van<ErrorState>) — dit component krijgt alleen de af te beelden tekst, nooit hetError-object.