Build the kitchen-sink table — filters, bulk actions, saved views, the lot
By the end of this recipe you'll have the end-state data table: server-paged, sort + filter, editable cells, bulk actions, column show/hide, saved views, CSV export, sticky first column, frozen header. Most apps don't need this. When they do, here's how it composes.
Overview
A toolbar of filter chips, a bulk-action bar that appears on row select, a column-visibility drawer, a saved-views menu, paged server data, inline editing on safe cells, and CSV export of the current view. All the layers, sitting on top of each other, in one composition.
This recipe is the synthesis of the other table recipes. The trick is that each layer is independent — toolbar doesn't know about inline edit; bulk action doesn't know about sort. Compose them in a fixed order and the result is predictable. Build them as a tangle and the table will become unmaintainable in 6 months.
This is the END state — most tables don't need it. A POS line items table needs editable cells, not column visibility. An audit log needs filters, not bulk actions. Reach for the simpler recipes first. The "everything" table is for when a power user lives inside it 8 hours a day.
Skip this recipe if you're not sure you need it. Skip if you'd ever describe the user as "casual" — this UI is for the operator who knows what they want and needs the shortest path to it.
What you'll build
Default with toolbar, two rows selected (bulk bar), and saved-views open.
All transactions
All transactions
All transactions · Views
Required pieces
Most of the kit, honestly. This is the composition test.
@corelithzw/react exports used here: DataToolbar, FilterChips, Checkbox, DropdownMenu, Popover, Button, Pagination, Drawer, Toast, useUrlState.
Step-by-step build
Layered state — selection, filter, view, columns, edit
Five independent stores. URL holds filter + sort + page (shareable). LocalStorage holds saved views + column visibility (user preference). Component state holds selection + edit (ephemeral). Don't merge them.
Three storage tiers — URL, LocalStorage, memory — each with a clear "who owns this" rule. The moment you blur them, two-way sync hell is unavoidable.
Toolbar swaps with bulk bar — same slot, different content
Reserve one horizontal strip above the table. When selection.size === 0, render the filter toolbar. Otherwise render the bulk-action bar. Don't stack them — that's two rows fighting for attention. Same slot, different content, one mental load.
Saved views — a serialized TableQuery, named
A saved view is just { name, q: TableQuery }. Store an array of them in localStorage. Switching a view = setQ(view.q). Saving the current view = { name: prompt('Name?'), q }. No backend required for v1.
Column visibility + sticky first column
A checkbox list in a Popover toggles a Set of visible columns. The first column is sticky to the left so the row label stays visible while scrolling horizontally. position: sticky; left: 0 on the <th> and <td> of column 0.
Bulk actions — confirm destructives, optimistic for safe ones
Delete/Void: alert-dialog ("Delete 12 transactions? This cannot be undone."). Export: just do it. Status changes: optimistic + Toast undo. The pattern: irreversible needs confirmation, reversible doesn't.
Final composition
~350 lines once you wire it all. The point is the composition tree — each layer is a small file underneath.
| Ref | Amount | Status | |
|---|---|---|---|
| TXN-0001 | $12.40 | Open | |
| TXN-0002 | $45.00 | Open |
Variations
Three subtractions — what to remove first when this is overkill.
Drop saved views
If users use 1–2 filter combos, hard-code them as tabs above the table. Saved views are for users with 5+ saved combos who switch hourly.
// remove ViewMenu + storage
<Tabs value={preset}
onChange={setPreset}>
<Tab value="all">All</Tab>
<Tab value="open">Open</Tab>
</Tabs>
Drop column visibility
If your row has < 6 columns, every column is essential. Hiding earns nothing. Keep the table fixed-column.
// remove ColumnMenu + storage
// inline ALL_COLS at render:
{ALL_COLS.map(c => (
<th key={c.key}>{c.label}</th>
))}
Drop inline editing
If edits trigger workflows (PO approval, audit trail), edits belong in a drawer with explicit Save, not inline. See the autosave-drawer recipe.
<td onClick={() =>
openDrawer(row.id)
}>
{row[c]}
</td>
// no EditableCell at all
Accessibility
More layers = more places to fail. These are the load-bearing ones.
- The toolbar swap is announced. Wrap the toolbar slot in
role="region" aria-live="polite"so screen readers say "3 selected" when the bulk bar appears. - Each Checkbox has an
aria-label. Per-row: "Select TXN-0001". Header: "Select all on this page". Without labels they're 30 unnamed checkboxes. - Saved views menu uses
role="menu". TheDropdownMenuprimitive does this for you — don't roll your own<ul>. - Sticky first column doesn't change tab order. Visual stickiness via CSS only; Tab still walks the cells in DOM order, left to right.
- Destructive bulk actions go through
AlertDialog. The dialog hasrole="alertdialog"; focus traps inside; Esc cancels; Enter on Confirm proceeds. - The "current view" badge has aria-current. In the views dropdown, the active item gets
aria-current="true"so AT users know which preset is loaded.