Home/ Cookbook/ Lists/ Simple list

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

Lists 2 primitives · 1 block · 0 patterns ~10 min React · @corelithzw/react

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.

Huchu

Active sites

PKPark branch12 staff
CBCBD branch9 staff
MSMsasa yard4 staff
AVAvondale kiosk2 staff
GWGraniteside warehouse7 staff
01 List — one row per site
Huchu

Active sites

PKPark branch12 staff
CBCBD branch9 staff
MSMsasa yard4 staff
AVAvondale kiosk2 staff
02 Tap a row, focus shows
Huchu

Active sites

No active sites yet.
Add your first site
03 Empty — explain + one CTA

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

01

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.

Code-only step

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.

Step 1 · The array@corelithzw/react
02

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.

PK
Park branch12 staff
CB
CBD branch9 staff
MS
Msasa yard4 staff
Step 2 · Render
03

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.

No active sites yet
Add a branch or yard to see it here.
Add your first site
Step 3 · Empty state

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

PK
Park branch12 staff
CB
CBD branch9 staff
MS
Msasa yard4 staff
AV
Avondale kiosk2 staff
SimpleSiteList.tsx@corelithzw/react

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. RowCard with onClick renders 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 (which RowCard already does).