One editor per entity: why Add and Edit share one screen
Why New and Edit should open the same form, at /groups/new and /groups/:id: one set of fields and checks, a real address, and ids that let creates work offline.
Anton Shubin8 min read
The tool: SeedOpen the live demoCode: github.com/spy4x/seed (opens in a new tab)
Contents
TL;DR
- In my apps, Add and Edit are one screen: New group opens the group's editor at /groups/new, and an existing group opens the same component at /groups/:id.
- One component means one set of fields, one check and one place for the loading, error and offline states, so create and edit cannot drift apart.
- A real address beats ?new: you can link to it and reload it, and after a create the address is replaced, so Back does not reopen an empty form.
- Ids made on the device let Seed create tags and notes offline and send them later with the same id. Groups still need a connection.
In my apps, Add and Edit are one screen. "New group" opens the group's editor at
/groups/new in create mode, and an existing group opens the same editor at
/groups/:id: the same component, the same fields, the same checks. One screen
for add and edit, never a one-field dialog for create and another form for edit.
This post is for developers and product people who build a create form and an
edit form for the same thing. I'll show the before and after from
Seed, my SaaS starter: why one component, why a real address
beats ?new, what offline-first adds, and where Seed does not do that part yet.
What was wrong with a "New group" dialog?
Link to section: What was wrong with a "New group" dialog?On 16 October I wrote down what annoyed me in Seed's app, and it became an issue. One item:
Always make "Add/New
" button to open the entity's editor (in "create" mode), not a dialog with only one field "name". [...] it must always be THE SAME COMPONENT.
Here is what it was about. "New group" opened a dialog with one field (trimmed from the pull request that removed it):
<Modal open title="New group" cancelLabel="Close" onClose={closeCreate}>
<ScreenForm pending={creating} onSubmit={onCreate}>
<Field id="group-name" label="Name" error={error} required>
<Input name="name" maxLength={100} value={name} required autoFocus />
</Field>
{/* Cancel and "Create group" */}
</ScreenForm>
</Modal>
Editing a group was a different thing: the name was renamed in place, and a separate "Details" dialog held the description, colour and emoji. So a group was born with a name only, and you had to find the second dialog to finish it. Tags, API tokens and invitations repeated the same dialog pattern.
Nothing in it was broken. It just looked like two features that happened to share a database table.
Why one component instead of two forms?
Link to section: Why one component instead of two forms?Because two forms for one thing drift. A new field has to be added twice. A check has to be written twice. The loading, error and offline states have to be drawn twice, and one of them gets forgotten. The person sees two different screens for the same group and learns two layouts.
Seed's routes now point both addresses at one view
(app.tsx):
"/groups/new": () => <GroupEditorView key="new" />,
"/groups/:groupId/:tab?": (params) => <GroupPage groupId={params.groupId} tab={params.tab} />,
GroupPage renders the same GroupEditorView with the group's id, and the view
picks the mode
(GroupEditorView.tsx):
{
groupId === null
? <NewGroup />
: <ExistingGroup groupId={groupId} tab={tab} />
}
Both branches draw one GroupEditorScreen. Create mode passes creating; edit
mode passes the saved group. What that buys:
- One set of fields. Name, description, emoji, colour and, since November, the group's time zone, in both modes. A create sends them all in one request.
- One check.
checkGroupDraftchecks the draft with the server's own parsers, so a typo is caught before the round trip, and bothcreate()andsave()call it (groups.ts). - One place for every state. Loading, "this group does not exist", errors under their field, saving, the offline notice. Written once, so create cannot forget one.
- Small, visible differences. Create mode says "New group" with Cancel and "Create group". Edit mode is titled with the saved name, and Discard and "Save group" show only after you change something. Leaving with unsaved changes asks first, in both.
The fields themselves come from my own Preact component library, so the editor is mostly wiring. The note editor already worked this way; it was the model. Seed's UI rules now say it as rule 2, "One editor per entity", so the next entity an app adds starts right.
Why /groups/new and not ?new?
Link to section: Why /groups/new and not ?new?Before, "New group" in the group switcher went to /groups?new, and the groups
page opened the dialog when it saw new in the query. A dialog over a list is
not a place. /groups/new is: you can link to it, reload it, and leave it with
Back, and the browser's history makes sense.
The interesting moment is right after the create. The address /groups/new is
now wrong: the group exists. So Seed swaps it for the group's page instead of
pushing a new entry
(GroupEditorView.tsx):
const group = await groups.create()
if (!group) return false
// The group is in the list before the address changes, so its page draws at once.
navigate(GROUP_PATHS.settings(group.id), { replace: true })
void selection.select(group.id)
replace: true is the whole trick. Back from the new group goes to where you
were before, the groups list in my case, not to an emptied "New group" form. The
new group also becomes your current one, since you made it to work in it, and a
toast says so. No reload: the route changes inside the app, and the same editor
draws again in edit mode, now with the Members, Billing and Activity tabs.
What does offline-first add?
Link to section: What does offline-first add?In an offline-first app the device makes the id, not the database. Seed gives
every new record a UUID version 7 on the client
(newId: () => uuidV7())
and sends it with the create. Since the id exists before the server answers, the
editor's address exists too.
Tags and notes go one step further: you can create them with no connection. Here is a tag I created in the demo with the network off:
What happened there:
- "Create tag" put the create into a queue on the device, with the tag's id.
- The tag showed up in the list, and the editor moved from
/tags/newto/tags/<id>in edit mode, exactly as it does online. - When the network came back, the queue sent
tag.createwith the same id (new-ids.test.tschecks exactly this). - If the queue sends a create again because an answer got lost, the server sees that the id already holds this tag and answers with it, so a retry cannot make a copy. Only a different tag under the same id is refused ("Tag id is already in use").
One editor helps here too: the group editor's offline notice is written once, with one sentence for creating and one for saving, and both modes show it.
Now the honest part. Groups don't do this yet. Creating a group needs the server. With no network, the group editor lets you fill in the form and says "You can fill this in now, but you need a connection to create the group." A create that fails keeps what you typed. Each try at a group also gets a fresh id, so the same-id protection covers tags and notes, not groups. The rest of Seed's offline layer is in the Seed post.
What else did one editor drag along?
Link to section: What else did one editor drag along?The same issue had more in it, and my AI agents built it in four pull requests in Seed's previous repository, on 16 and 17 October (how I run them is in my AI coding agent setup):
- Tags got
/tags/newand/tags/:tagIdfirst, with oneTagEditorScreen. - Groups got the editor above. Clicking a group in the list now opens its editor, not its notes, and the "Open notes" button is gone: notes are not the only thing an app will ever have.
- API tokens and invitations are
created on pages too, at
/tokens/newand/groups/:groupId/invitations/new. They are never edited, so their pages are create-only, in two steps: the form, then the token or link shown once, with "Leave without copying?" if you try to leave first. A dialog that shows a secret once is very easy to close by accident. - The header bar went away, and the sidebar is now the app's only frame. Not about editors, but the same issue.
So the next time a "New something" button opens a dialog with one field, ask what the edit screen looks like. If the answer is "different", you have two forms, and they have already started drifting.
Get the next post by email
Anton Shubin
Senior Full-Stack Engineer & Tech Lead
I build greenfield SaaS on a modern, lightweight stack, alone or with a team of senior developers from my own pool, and I cover full-stack, DevOps and architecture.
New posts on decisions for founders, developer craft, AI and MCP, self-hosting, and my own mistakes, about once a week at most, and only when there is one. Unsubscribe with one click.
Questions, or a different result? Reply by email: hi@antonshubin.com. It comes straight to me.
