Build a simple list — one row per item, no extras
By the end of this recipe you'll have the smallest possible list view: an array, a row component, and a tap-to-open detail. No virtualisation, no filters, no toolbar — the floor of "what is a list".
Overview
An array of items, rendered as rows. Tap a row, you get its detail page. That is the whole recipe.
Most apps reach for a data table the moment they have more than three things to show. That's almost always wrong. A list of 8 active job-sites doesn't need a column header, a sort dropdown, a chip filter or page navigation — it needs a row per site and a place to tap. The point of this recipe is to give you a clean stopping point: render the rows, stop building.
The opinionated bit is the row component. Use RowCard, not a hand-rolled <div> with flexbox. RowCard already knows how to wrap a title, supporting text, a trailing meta and an action; it already handles tap, keyboard focus and the "press-and-hold to multi-select" gesture if you ever add it. Hand-rolled rows always end up reinventing those three things, badly.
Don't reach for a table for 5 items. A list is fine. Tables exist to compare values across columns; if your "table" only has one column the user actually reads, you have a list — render it as one.
Skip this recipe if you need any of: filtering, sorting, grouping, pagination, virtualisation, bulk-select. The moment you need one, jump to the right recipe — they each cost more code, and starting here and adding piecemeal is how you ship an inconsistent UI.
What you'll build
Three screens. The list, a selected-and-tappable row, and the empty state when the array is empty.
Active sites
Active sites
Active sites
Add your first site
Required pieces
Everything this recipe pulls from @corelithzw/react. Tiny surface — that's the whole point.
@corelithzw/react exports used here: Stack, RowCard, EmptyState, Button. All four ship in v0.1-alpha — no roadmap-only pieces.
Step-by-step build
Start with the array — no useState, no fetch
For the smallest list, your data is just a constant or a prop. Don't add useState "in case you need to mutate later" — when you need to mutate, that's the moment to lift the state. Premature state is how lists turn into 200-line components.
5 items. No fetch, no loading state, no error path.
If your data crosses ~50 items, switch to a paginated table.
Switch to the Code tab to see the snippet.
Render with RowCard inside a Stack — not a table, not a flex hack
Stack handles the inter-row spacing token; RowCard handles the row itself — title, trailing meta, tap target. The whole list is six lines. If you find yourself adding a custom CSS class to the row, stop — the row should look like every other row in the app.
Handle empty with EmptyState + one CTA — don't render nothing
An empty screen is not an empty array — it's a screen that has to teach the user what's missing and tell them how to fix it. One sentence of context plus one action. If you can't think of an action, ask whether the screen should exist at all.
Final composition
The whole list component. About 40 lines. If yours is longer, you're probably building a different recipe — go check the others.
Active sites
Variations
Three forks where the floor still applies. Each is a small swap, not a rewrite.
Read-only list
No detail page — just rows displaying values. Drop the onClick and the row becomes a static cell. Use this for sidebars and summary panels.
// remove onClick — RowCard renders
// without focus ring + tap target
<RowCard
leading={<Avatar />}
title={site.name}
trailing={site.staffCount}
/>
With trailing action
A small icon button on the right (open menu, archive). The whole row still navigates; the icon stops propagation and runs its own thing.
<RowCard
title={site.name}
trailing={
<IconButton
icon="more"
onClick={(e) => {
e.stopPropagation();
openMenu(site.id);
}}
/>
}
onClick={() => onOpen(site.id)}
/>
With supporting line
Two-line row: title above, dim secondary text below. Use for "address under name" or "status under title" — not for three columns of data.
<RowCard
title={site.name}
description={site.address}
trailing={`${site.staffCount}`}
onClick={() => onOpen(site.id)}
/>
Accessibility
A simple list is small enough that you have no excuse to skip these.
- The container is a list.
<Stack as="ul" role="list">+RowCard as="li"gives screen readers the "5 items" count up-front so users know how long they're in for. - Rows are buttons, not divs.
RowCardwithonClickrenders a real<button>internally, so Space and Enter both trigger it, and Tab lands on each row in order. - Avatar initials are
aria-hidden. The name is in the title; reading "PK Park branch" twice in a row is noise. Hide the decoration. - The list has a label.
aria-label="Active sites"on the list so screen-reader users know which list they entered from a heading-jump. - Empty state owns the action. The "Add your first site" button is the only focusable thing on the empty screen — Tab lands on it, Enter fires it. No hunting.
- No fake clicks. If you add
role="button"to a div, you've made the list less accessible than before. Use a real button (whichRowCardalready does).