Skip to content

Recipe: A list screen with actions

Goal: the most common screen in any app — a table of things with an action bar to add one, and selection-aware actions that work on the selected row. This recipe wires displaying data and running commands together.

Prerequisites: the providers and styles from Getting started, an observable query proxy (AllAuthors), and a CommandDialog component for each action. AddAuthor comes from the form recipe; AddBookDialog takes an authorId prop and passes it to AddBook through initialValues, as in the tutorial.

  • A live table reads an observable query, so it reflects changes the instant a command lands.
  • An action bar item opens an “add” CommandDialog.
  • Selection-aware actions are disabled until a row is selected, then open a command dialog for that row.
Authors.tsx
import { useState } from 'react';
import { useDialog } from '@cratis/arc.react/dialogs';
import { Column, DataPage, MenuItem } from '@cratis/components/DataPage';
import { FaBook, FaPlus } from 'react-icons/fa6';
import { AllAuthors, type Author } from './Authors/Author'; // observable query proxy
import { AddAuthor } from './AddAuthor';
import { AddBookDialog } from './AddBook';
export const Authors = () => {
const [selected, setSelected] = useState<Author | null>(null);
const [AddAuthorWrapper, showAddAuthor] = useDialog(AddAuthor);
const [AddBookWrapper, showAddBook] = useDialog(AddBookDialog);
return (
<div style={{ height: '100vh' }}>
<DataPage<AllAuthors, Author, object>
title='Authors'
query={AllAuthors}
emptyMessage='No authors yet'
onSelectionChange={(event) => setSelected(event.value)}
>
<DataPage.MenuItems>
<MenuItem
label='Add author'
icon={FaPlus}
command={() => void showAddAuthor()}
/>
<MenuItem
label='Add book'
icon={FaBook}
disableOnUnselected
command={() => {
if (selected) {
void showAddBook({ authorId: selected.id });
}
}}
/>
</DataPage.MenuItems>
<DataPage.Columns>
<Column field='name' header='Name' sortable />
</DataPage.Columns>
</DataPage>
<AddAuthorWrapper />
<AddBookWrapper />
</div>
);
};

MenuItem.command receives no arguments, so the screen keeps the selected row from onSelectionChange. The explicit type arguments make event.value an Author; without them, and without a typed detailsComponent, TypeScript infers the row as object. DataPage still tracks the selection itself for disableOnUnselected and the optional details panel. Give DataPage an ancestor with a definite height; the wrapper above uses 100vh.

You never write the glue between the write and the read: the command changes the data, the observable query pushes its new result, and the table re-renders — automatically. With Chronicle, the new result arrives once the projection has updated the read model. There’s no “refresh the list after saving” code because there’s nothing to refresh.

  • Edit and remove are just more commands. Model them as commands (for example RenameAuthor and RemoveAuthor), add a disableOnUnselected menu item for each, and pass the selected row’s id through the dialog’s initialValues; the table updates itself.
  • Keep the table’s read model specialized. The list query and a detail query can read different, purpose-built read models — don’t force one model to serve every screen.
  • Show the selected row’s details by passing a detailsComponent; see DataPage details panel.
  • For a plain table without the action bar and details panel, use DataTableForObservableQuery directly.