Skip to content

06 - Flows

End-to-end user journeys. A pattern describes one moment; a flow describes a sequence of them.

Every flow here is canonical. Deviations need justification in code review. Consistency across apps is the point.

Prereqs: 04-components.md, 05-patterns.md.


CRUD — the backbone flow

Create, Read, Update, Delete. Every entity in every app goes through this skeleton.

Create

Two patterns — pick one per entity class.

Pattern A — quick create in a Dialog (preferred when the entity has ≤ 4 fields to get started).

List page
├─ Primary action: "+ New <Entity>" in page bar
├─ Opens <Dialog> titled "Create <Entity>"
├─ Minimal required fields (name, type, maybe 1-2 more)
├─ DialogFooter: [Cancel] [Create <Entity>]
│ - Create button = ActionButton with pending/success/error
│ - errorPlacement="below" in dialog context
├─ On success:
│ ├─ Close dialog
│ ├─ Navigate to the new entity's detail page
│ └─ Focus the entity name or first editable field
└─ On error:
- Stay in dialog
- FormError above action bar with server message

Pattern B — dedicated create page (when the entity needs many fields or a preview).

List page
├─ Primary action: "+ New <Entity>" → navigates to /<entities>/new
New page (Form archetype)
├─ ListPageBar title "New <Entity>"
├─ PageContent narrow
├─ FormSection groups for logical field groups
├─ FormError above ActionBar
└─ ActionBar: [Cancel] [Create]
- Cancel = navigate back to list (warn if dirty)
- Create = ActionButton, onSuccess navigates to detail page

Read

The detail page (Detail archetype).

List page → click row → detail page
├─ DetailPageBar back → parent list URL
├─ Tabs = logical sub-sections (max 8)
├─ Default tab is the most-used (not alphabetical)
└─ Deep-linkable: /entities/:id/<tab> works directly

Rules:

  • Row click opens detail (whole row is clickable, not just the name).
  • Don’t open detail in a new tab by default — Cmd-click does that natively.
  • If the detail is view-only, use the Detail archetype without ActionBar.
  • If the detail is editable, it’s a hybrid (Detail + Form) — use the same tabs, ActionBar on the form tab only.

Update

Edit patterns follow three-tier inline edit (see 05-patterns.md).

Settings-style update (persistent form)

Detail page, tab = "Settings"
├─ Form fields pre-populated
├─ isDirty tracked (react-hook-form)
├─ ActionBar: [Reset] [Save]
│ - Save disabled when !isDirty
│ - Save = ActionButton, successText="Saved"
├─ Unsaved changes warning:
│ ├─ Block navigation (useBlocker)
│ └─ Unsaved-changes dialog: "Discard changes?" [Keep editing] [Discard]
└─ On success:
├─ ActionButton shows ✓ Saved (1500ms)
└─ Form resets dirty state

Inline update (click-to-edit or icon-edit)

Display: "Name: Acme Corporation"
├─ Click or pencil-icon → input appears in place
├─ Commit on Enter or blur (not on every keystroke)
├─ Escape cancels (revert to original)
└─ On commit:
├─ Optimistic UI update
├─ Mutation fires
└─ On error: revert + inline error + shake

Delete

Two-tier by blast radius — see 04-components.md → ConfirmPopover / ConfirmDialog.

Routine delete (per-row)

Table row
├─ Ghost icon-xs trash button with ConfirmPopover
├─ Popover: "Remove <item>?" [Cancel] [Remove]
├─ Confirm → mutation
├─ On success:
│ ├─ Optimistic remove from list
│ └─ Optionally: bottom toast with "Removed. Undo" for 5s
└─ On error: revert + inline error

Catastrophic delete (whole entity)

Detail page → Danger Zone section (always last)
├─ [Delete <Entity>] destructive button triggers ConfirmDialog
├─ Dialog: "Delete this <entity>?"
│ description: "This permanently deletes the entity, all its data..."
│ confirmLabel: "Delete" (the verb, not "OK")
├─ Extra friction for truly irreversible: type the entity name to confirm
└─ On confirm:
├─ ActionButton on the dialog's Delete button
├─ On success: close dialog, navigate to parent list, toast "Deleted"
└─ On error: stay in dialog, FormError

Auth flows

Sign in

Keycloak-backed. The app’s job is a dumb form.

Unauthenticated route access
├─ AuthProvider checks isAuthenticated()
├─ If not: render <LoginScreen>
│ ├─ Email + password (password-input with reveal toggle)
│ ├─ "Sign in" = ActionButton
│ └─ On success: reload to target page
└─ On error: FormError inline with server message

Rules:

  • No “Remember me” checkbox — that’s a setting we shouldn’t need.
  • No “Forgot password?” link in the form if SSO-only. Add it only if email+password is actually supported.
  • After sign-in, redirect target = URL’s ?redirect= param, sanitized via sanitizeRedirect().

Session expiry

Silent refresh via updateToken() from @na/auth. If refresh fails:

Token refresh fails
├─ toast.error('Session expired. Please sign in again.')
├─ Redirect to /login?redirect=<current-path>
└─ AuthProvider shows LoginScreen

No confirm modals. No “your session is about to expire” pop-up. It just works or it prompts for sign-in.

Sign out

Sidebar footer avatar → dropdown → "Sign out"
├─ Confirm modal? NO. Sign out is not destructive, it's expected.
├─ Call logout() from @na/auth
└─ Reload to /login

Onboarding — first-run experience

The user created an account, landed on the product. They see an empty list.

Zero-state → first entity

First page load with zero entities
├─ EmptyState (blank-slate variant)
│ ├─ Icon
│ ├─ Title: "No agents yet"
│ ├─ Description: concrete, explains the value
│ └─ Action: "Create your first agent"
├─ Action → opens the same Quick Create Dialog
└─ After creation → navigate to detail page with pre-filled sensible defaults

Rules:

  • Do not show a tour modal on first load (users dismiss tours instantly).
  • Do show inline hints via <Tooltip> on first-use elements (only visible first time, dismissed on interaction).
  • Never re-show first-run onboarding to returning users (A8 — a static invitation fires once).

Second-entity prompt

After the first entity is created, the next visit shows the populated list. No “great job, create another!” celebration. The product is the celebration.


Bulk actions

When users need to act on multiple entities at once.

List page with checkbox column
├─ Click row checkbox → row selected (bg-muted/50 + check)
├─ When ≥ 1 selected:
│ ├─ ContentToolbar appears (or existing toolbar changes mode)
│ ├─ Left side: "<N> selected" + [Clear selection]
│ └─ Right side: bulk actions — Delete, Export, Change status, etc.
├─ Bulk action clicked → ConfirmDialog (catastrophic) or ConfirmPopover (routine)
│ ├─ "Delete <N> contacts?"
│ └─ [Cancel] [Delete <N>]
├─ On confirm:
│ ├─ Disable action button (ActionButton pending)
│ ├─ Progress indicator if > 5s (e.g., "Deleting 23 of 47...")
│ └─ On success: clear selection, toast with count, optionally "Undo"
└─ On partial error:
├─ Show which items failed and why
└─ Allow retry on just the failed subset

Rules:

  • Select-all checkbox in the table header: only visible when the column exists.
  • Max 1000 items acted on at once. Above that, use a background job with progress page.
  • Ctrl/Cmd-click range selection is optional but nice.

Multi-step wizard

For flows with > 5 logical inputs that naturally split into steps (new-agent setup, import mapping, billing upgrade). Uses the Focus archetype — see 03-layout.md § Named page archetypes and references/attio-layout-decision-guide.md § Decision tree 2.25.

Step indicator (breadcrumb-like)
[● Step 1] [○ Step 2] [○ Step 3]
active next pending
Body: step content
Footer: [Back] [Next] or [Skip] if optional
Final step: [Back] [Finish]

Sizing rules

  • Show progress (step N of M) at top. Linear, not a free-form tab switcher.
  • Never have a wizard with 1 step. Use a Dialog.
  • Never have a wizard with 8+ steps. Split into sub-flows or cut.
  • On the last step, the button label = the outcome (“Create agent”, “Start import”).

State machine

Use useReducer (not per-field useState) for any wizard with ≥ 3 fields total. The reducer captures the form draft, the current step, and a derived isDirty flag. One source of truth makes step preservation, validation, and submit straightforward.

type WizardState = {
step: number;
draft: { template?: string; name?: string; tools?: string[]; ... };
isDirty: boolean;
};
type WizardAction =
| { type: 'pickTemplate'; template: string } // not dirty (see below)
| { type: 'setField'; field: keyof Draft; value: string } // sets isDirty
| { type: 'toggleTool'; tool: string } // sets isDirty
| { type: 'next' }
| { type: 'prev' }
| { type: 'jumpTo'; step: number }; // only allowed for completed steps

For wizards with ≤ 3 simple fields, plain useState is fine. The threshold is “would I lose track of which setX call updates which slice of state?”

URL state vs component state

Goes in URLStays in component stateNever persisted to localStorage
?step=N (so browser-back works)All form input (name, description, …)Wizard draft (any field)
?<starter>=<id> for low-info starter choicesisDirty flag
Validation error messages

Field data does NOT go in the URL — it’s high-cardinality, possibly sensitive, and too long. Refreshing or sharing a wizard URL starts the user fresh from Step 1; partial-fill recovery is a separate (Plan-2-style) feature, not the default.

Validation

  • Validate-on-Next, not validate-on-blur. Blur-validation interrupts the user’s typing flow during a step; the user is happy to be told once at step transition that something’s wrong.
  • Block advance if the current step is invalid; refuse the Next click and focus the first invalid field.
  • Preserve all state on back-nav. Hitting Back from Step 3 to Step 2 returns to Step 2 with every field exactly as the user left it. Same in reverse — going forward to Step 3 again, the data is still there.

Dirty-state rule

Dirty = “user has typed text in an input OR toggled a default selection.” Dirty triggers the close-confirm flow.

NOT dirty:

  • Picking a starter / template card on Step 1. Starters are scaffolding choices, not user content the user would mourn losing. Triggering “discard your draft?” after a single click is friction without value.
  • Visiting a step (advancing to Step 3) without changing anything on it.

This is the difference between “I picked a starter” (no draft) and “I typed my agent’s name” (draft).

Multi-API submit

If the final commit needs N backend calls, prefer one atomic backend endpoint that accepts everything (e.g. extend POST /v1/agents to take tools[] instead of POST /v1/agents then PUT /v1/agents/:id/tools). Bundle the backend work into the same ticket as the frontend wizard.

If atomic isn’t possible, the rules:

  • Sequential, not parallel. Order matters (you can’t attach tools to an agent that doesn’t exist yet).
  • Last-step retry. A failure leaves you in a partial-success state. Surface a non-blocking banner on the entity’s landing page: “Couldn’t attach tools — add them from the Tools tab.” Don’t try to roll back.
  • Never auto-DELETE on partial failure. A DELETE /v1/agents/:id on a possibly-already-created agent is risky if the create succeeded server-side but the client never saw the response. The user can manually delete a half-configured entity if they want; the wizard shouldn’t decide for them.
  • Idempotency keys if the backend supports them — preferred over rollback.

Browser-back during in-flight submit

User clicks the final “Create” button → mutation pending → user hits browser back. Allow it. The mutation continues in the background; the new entity appears in the destination list when TanStack Query (or equivalent) invalidates the list onSuccess. Don’t useBlocker the navigation — that’s modal-stacking by another name.

Exit-path close behavior — never silent

A wizard has four ways the user can leave: ✕ button, Cancel button (in the action bar), Esc key, sidebar/back-navigation. Every one of them is observable, and every one MUST do something visible when the draft is dirty:

  • Not dirty: all four navigate immediately to closeTo (no prompt — the user hasn’t typed anything to lose).
  • Dirty (✕ / Cancel): wrap each trigger in its own <ConfirmPopover> anchored to itself. Same copy, same destructive variant.
  • Dirty (Esc): programmatically click the ✕ ref to open its popover. No new keyboard-only confirm path — reuse the visual one.
  • Dirty (sidebar / browser back): intercept with React Router’s useBlocker (or equivalent) and show a <ConfirmDialog>. This is the one case that escalates to a modal, because there’s no in-flow trigger to anchor a popover to.

Hard rule: a silent no-op on any exit path is forbidden. Returning early from a close handler with no UI feedback (the easy bug) makes the button look broken. If isDirty && !isPending, the user must see a prompt — every time, on every path. The compliance script doesn’t catch this — reviewers must.

One confirm copy — don’t render different messages for ✕ vs Cancel vs Esc. The user shouldn’t have to learn multiple flows for the same outcome.

See 04-components.md § FocusLayout § Confirm-on-discard for the full table and the implementation pattern.

Save state on Next

Save the form draft to component state on every Next. Do NOT persist mid-wizard drafts to localStorage — that creates “stale draft” UX problems on next visit (the user comes back days later, doesn’t remember what they were doing, ends up confused). Refresh-loses-work is the right tradeoff for an occasional task; if the wizard is daily-use, that’s a sign it’s the wrong archetype (use Form, not Focus).

Lazy-load the route

Wizards live at non-default routes (/agents/new, /import/contacts). Use React.lazy(() => import('./AgentCreatePage')) so the more-frequent route (/agents, /contacts) doesn’t pay the wizard’s bundle cost. Adds ~50ms to wizard first-paint, saves ~5–10KB gzipped on every list-view load.

Test pattern

See 09-i18n-testing.md § Wizard test pattern for the mandatory test file layout. Wizards earn 5 test files (page-level reducer + per-step + 1 happy-path E2E if Playwright is configured).


Save / commit semantics

Three models. Pick one per surface and stick to it.

Model A — explicit Save (the default)

User edits → ActionBar shows isDirty → click Save → mutation.

  • Use for: settings pages, long forms, any complex edit.
  • Dirty-tracking via form.formState.isDirty.
  • Unsaved-changes guard blocks navigation.
  • Save button disabled when clean.

Model B — instant apply (Switch and similar)

User toggles → saves immediately. No “Save” step.

  • Use for: feature toggles, notification preferences, boolean settings.
  • Show brief inline confirmation (ActionButton’s success tick, or field-level “Saved” ephemeral).
  • Roll back visibly on error.

Model C — auto-save with debounce

User edits → debounce 1-2s → save in the background → show “Saved” / “Saving…” in a quiet corner.

  • Use for: rich-text content (prompt editor, notes), canvas layout (workflow positions).
  • Always show the last-saved state; never let the user think “did that save?”
  • Offer explicit Save as a fallback (Ctrl+S).

Never mix Model A and Model C on the same page. Users can’t tell what saves when.


Undo

For any reversible mutation, offer Undo. Not every mutation is reversible; don’t fake it.

Delete a row
├─ Optimistic remove + bottom toast: "Contact removed. [Undo]"
├─ Toast persists for 5s
├─ Undo click:
│ ├─ Call the undo mutation
│ └─ Restore the row
└─ After 5s, commit becomes permanent

Rules:

  • Undo window: 5s for single-entity mutations, 10s for bulk.
  • Undo is optimistic: the original row stays “deleted” visually, but a restore mutation fires on undo.
  • Show a spinner if the undo restore is slow.
  • Don’t offer undo for truly irreversible actions (payment, send email).

Long-running jobs

Imports, exports, bulk operations that take > 10 seconds.

Inline progress

For jobs < 30 seconds where the user waits:

[Run import] → button becomes ActionButton pending
├─ Progress bar inline: "Processing 234 of 1,000 rows"
├─ Cancel button (if cancellable)
└─ On success: summary of results with [Close] or [View items]

Background jobs

For jobs > 30 seconds — let the user leave and come back:

[Start import] → job queued
├─ Toast: "Import started. We'll notify you when it's done."
├─ Notification badge on the sidebar item (polling or SSE)
├─ User can navigate freely
└─ On completion:
├─ toast.success("Import complete: 1,247 contacts added")
├─ Badge clears
└─ Optional: Result page or direct link to the imported items

Jobs should have a “Jobs” page showing history + status. Essential for debugging.


Export / download

[Export] button → opens popover or dialog with options:
├─ Format: CSV / JSON / Excel
├─ Scope: All / Filtered / Selected
├─ [Cancel] [Export]
Generating...
├─ Progress if > 3s
└─ On success:
├─ Browser download triggers automatically
└─ Toast: "Export ready" with a link if download blocked

For large exports, treat as a background job (see above).


Import

[Import] button → Import wizard
├─ Step 1: Upload file (CSV)
├─ Step 2: Map columns (our field ← CSV column)
├─ Step 3: Preview first 10 rows with validation highlights
├─ Step 4: Confirm and start
├─ Progress (inline for < 30s, background otherwise)
└─ Result page:
├─ Success count
├─ Warning count with drill-down
├─ Error count with drill-down (export failures for fixing)
└─ [Close] or [View imported items]

Duplicate (entity copy)

Row action (or detail page action) → "Duplicate"
├─ Optionally: rename dialog with proposed name "<Original> (copy)"
├─ On confirm: mutation
├─ On success:
│ ├─ Navigate to new entity's detail page, OR
│ └─ Show row with animation + toast: "Duplicated. [Open]"
└─ Never call it "Clone" — stick to "Duplicate"

Filters, selected rows, tab state — all in URL params so the page is shareable and refresh-safe.

/agents?status=active&sort=name&page=2
├─ URL is the source of truth
├─ Filters, sort, pagination sync with URL
└─ On load: parse URL params, apply

Back + forward

  • Back from Detail → parent list.
  • Back from Create page → list.
  • Back from a wizard step → previous step.
  • Back from the root of a nested context (app switch) → previous app.

Use React Router’s useNavigate(-1) only when you’re sure of the history. Default to explicit target URLs.


Error recovery flows

Network error on load

PageContent with Skeleton → fetch fails
├─ Replace skeleton with error state:
│ ├─ Icon
│ ├─ Title: "Couldn't load contacts"
│ ├─ Description: "Check your connection and try again."
│ └─ [Retry] button
└─ Retry click → re-fetch with Skeleton again

404 on detail page

Fetch returns 404
├─ Show Exception page (Exception archetype)
│ ├─ Icon: AlertCircle
│ ├─ Title: "Contact not found"
│ ├─ Description: "This contact may have been deleted or the link is wrong."
│ └─ [Back to contacts] [Go home]

Permission denied

Fetch returns 403
├─ Show Exception page
│ ├─ Icon: Lock
│ ├─ Title: "You don't have access to this"
│ ├─ Description: "Ask your admin to grant <permission> to your account."
│ └─ [Contact admin] or similar

Never redirect silently to a different page — the user needs to know why they can’t see what they expected.


Flow anti-patterns

Don’tDo
Show a tour modal on first loadInline tooltips on first-use elements
Toast after a click-triggered mutationInline ActionButton feedback
Require “OK” confirmation for non-destructive actionsJust do the thing
”Are you sure?” on every saveOnly on destructive / irreversible
Two primary buttons in a neutral decisionBoth outline
Success message that auto-dismisses after 500ms1500ms minimum; let users see it
Clear form fields on submit failurePreserve the data; show the error
Empty undo toast (“Removed.”)Include item name (“Removed contact Jane Doe”)
Wizard with one stepUse a Dialog
Back button that uses history.back()Explicit target URL
Infinite scroll on admin tablesPagination
Auto-save + explicit Save togetherPick one model

Next: 07-voice.md.