← Back to registry

Entity detail/edit uses a routed panel, not a dialog

Drill into or edit an existing entity in a URL-driven detail panel; reserve dialogs for disposable create/confirm/wizard flows.

Torben Sko @torben v1 frontend:ui-conventionsfrontend:routing
Install in Blint

Opens the Blint desktop app and adds this pattern to your project. Don't have Blint?


The deciding line for "click to see/edit more" is whose content it is and where the open-state lives, not how big the surface is.

  • Existing content → a URL-driven detail panel. Viewing or editing an entity that already exists is the default case, and it belongs in a slide-over panel bound to the route — …/reminders/:reminderId, …/alerts/:alertId. The id lives in the URL path, so the open state survives a refresh and a copied link reopens the same surface. The panel keeps the surrounding list/context in view, which reads better for drill-down than a centred modal.
  • New or transient content → a Dialog. A create form has no entity yet, so it is disposable until saved — a dialog is correct. Confirmations and genuine multi-step workflow/wizard modals also stay dialogs.

The create flow: a minimal dialog, then redirect to the edit surface

Create dialogs are correct, but they must be minimal, not a copy of the full edit form. The standard creation flow is:

  1. Click a "New …" button.
  2. A small dialog captures only the fields required for the entity to validly exist — typically just a name/title, plus any required parent FK (a child entity needs its parent) and required association. Boolean enable/active flags default to false (or their safe default); the full editor calls them out.
  3. On submit the entity is created and the user is redirected to the entity's routed edit surface (onSaved(id)navigate(.../thing/:id)) to capture the remaining, optional detail.

The reason is momentum: a full form inside a dialog is lost wholesale to an accidental ESC or click-off. A minimal dialog has almost nothing to lose, and the real editing happens on a stable, linkable, refresh-proof route. A minimal create dialog may host its few required fields inline — a Dialog–Form separation rule targets complex, reusable form bodies, not a name field.

A consequence of this flow is that forms stop being dual-mode. Because the entity always exists by the time the edit surface loads, the edit page/panel is update-only: no id === "new" branch, no create mutation, no navigate(..., { replace: true }) to swap a placeholder id for the real one. Remove */new routes and the create branch from editor pages — creation lives in the dialog, editing lives on the route.

The redirect is the default, not a law: rapid-build surfaces stay put

The redirect (step 3) exists to move detailed editing onto a stable route. Where the whole point of a surface is to add many siblings in quick succession — a hierarchy tree where you rough out a parent and its children several levels deep, or any batch-entry workflow — redirecting out of the surface on every create destroys exactly the momentum the pattern is trying to protect. Such a surface is a sanctioned exception: the minimal create dialog still applies (and still produces a valid, sparse entity with a name + required parent), but on submit it stays in place (onSaved invalidates the list and closes the dialog, no navigate). The full editor is still one click away via the row's drill-in. The same dialog component supports both: the host decides whether onSaved redirects, so the standalone list page redirects while the rapid-build surface does not.

The carve-out is for rapid sibling entry, not a general licence to skip the redirect. When in doubt, redirect. If a surface claims the exception, say so where it's wired up, so the divergence is a documented UX choice rather than an accident.

What this pattern flags

A Dialog is being used where a routed panel should be when any of these hold:

  • The page keeps an entity id in component state (editingSiteId, selectedAlert, activeReminder, …) purely to decide whether a dialog is open. That id belongs in the URL path, driving the detail panel.
  • A dialog hosts an edit form for an existing record — i.e. it loads the entity by id and submits an update. The edit path must be a panel.
  • A combined "create and edit" dialog branches on id ? "Edit" : "Create". Split it along the line above: the create path stays a dialog, the edit path becomes a routed panel, and both mount one host-agnostic form body.

A create flow also needs flagging when:

  • A create dialog hosts the full edit form (every optional field) rather than the minimal required set. Slim it to the required fields and redirect to the edit surface for the rest.
  • An entity has a */new route or an editor page that branches on id === "new". Creation should run through the minimal dialog; delete the /new route and the create branch, leaving the editor update-only.

What is already compliant — do not "migrate" these

  • Minimal create dialogs (open from a "New …" button, no entity id, capturing only the required fields then redirecting to the edit route) — correct as dialogs.
  • Full detail pages routed at their own path (…/meters/:meterId) — already URL-driven, so already compliant. They are not panels-in-waiting.
  • Confirmation and wizard dialogs (e.g. an import workflow that builds several entities atomically) — out of scope; they may capture more up front.

The migration recipe

  1. Extract the dialog's form into a host-agnostic body that owns its field state, validation, and busy/error UI, and takes an onSubmit(data) the host provides. The host owns the mutation (create vs update) and the close/navigate.
  2. Keep a create-only dialog that calls the create mutation.
  3. Add a sibling route (…/thing/:thingId) rendered by the same page, read the id via useParams, fetch the entity, and render the shared form inside a detail panel whose open is driven by the presence of that id. Closing navigates back to the base path.

Keep the panel shell router-agnostic; the URL binding (useParams / navigate) is owned by each app's page, not abstracted into the primitive.