September 27, 2026 · 3 min read
Admin screens, joined not generated
An editor that joins a form to its mutation, a list that joins a query to a selection, a remover that asks first: how foldkit-crud builds management screens without generating anything.
The list of posts in the studio is a table: each post’s title and state, a search box
above it and a tab for the archive. It is foldkit-crud, drawn by foldkit-mixins-crud.
Crud is one of the smallest packages here, because it owns almost nothing.
Joined, not generated
A tool that makes an admin screen from a table has to guess: which fields, in which order, and what a save does. Crud does not guess. It joins parts the application already declared, and each keeps its own job:
- a form says what may be entered, and when it is valid (
foldkit-form); - a mutation says what a save does on the server (
foldkit-remote); - a query says what a list holds, and a selection which of its fields are shown (
foldkit-entity); - Remote’s store holds what was loaded, and whether a save is pending, done or failed.
What Crud adds is what sits between them: which record is open, whether its value has been put in the form yet, and which save is this screen’s.
A list
A list is a query and a selection. The studio’s list of posts:
export const WorklistList = Crud.list('Worklist', {
query: Cms.Entries,
selection: EntryRow,
})
export const Worklist = WorklistList.at({
data: Data,
input: (model: Model) =>
Option.some({
type: Posts.name,
search: model.search,
archived: model.archived,
}),
})The query’s input is read from the Model, so the search and the archive tab are
the application’s own state, and when either changes, the list needs another page
and Remote reads it. A list keeps no state of its own. Its pages are in Remote’s
store: Worklist.page(model) reads them, and Worklist.more(model) asks for
the next. A read that failed is not retried by itself: Worklist.refresh(model)
is the retry, and the table can offer it as a button.
Drawn as a table
foldkit-mixins-crud draws a list as an accessible table. Its columns come from the
selection, each labelled by its Schema’s title, and each cell is drawn by its
field’s Display: text, a number, yes or no, a reference. The CMS adds a Display
of its own, the coloured state badge, and hands its renderer to the table (trimmed):
WorklistTable(
{
page: Worklist.page(model),
onOpen: row => Message.OpenedEntry({ entry: row.id }),
renderers: Cms.displayRenderers(),
words: { empty: 'Nothing yet. Start a post.' },
},
h,
)Opening a row is a Message the application passes in, so the table never decides what opening means. While the first page loads, the table’s status line is marked busy, and this site shows it only if the wait is long. An empty list says so in words the application chose.
An editor
Crud.editor joins a form to the mutation its value feeds:
const Editor = Crud.editor(
'PostEditor',
{ form: EditPostForm, mutation: EditPostMutation },
)Opening a record empties the form and asks Remote for the fields the form edits. When they arrive, the form is filled. A valid submit becomes the mutation, and the editor’s status says where it is: Loading, Editing, Saving, Saved, or SaveFailed with the reason. Remote holds the value and the save, and the form holds the drafts. The editor holds only which record is open and which save is its own.
This blog’s editor is foldkit-cms’s rather than Crud’s, because a post saves
to a draft beside its row, not to the row. It has the same shape (open, load,
fill, submit, report) with drafts, publishing and revisions added.
Deleting, details and pickers
Crud has three more parts, which this demo does not need:
Crud.removerdeletes through a mutation with a yes in between. It asks, waits for Confirmed or Cancelled, and reports Deleting, Deleted or DeleteFailed. When the server says what it deleted, Remote drops it from every list, and an editor open on it reads NotFound.Crud.detailreads one record through a selection, to show as labelled fields.Crud.optionsfeeds a form’s relation pickers from lists: an author picker from a list of authors. A picker that searches hands its search text to the list as its input.
Each is state or a reading, like the list and the editor. None draws anything, and none keeps a copy of what Remote holds.