User feedback (toasts & confirmation)
This page is the policy for when to proactively confirm an outcome to the user — chiefly success toasts, and where failures defer to the error engine. Error handling owns the failure half (dispositions and surfaces); this page owns the "did we tell the user it worked, and should we?" half that the disposition tables don't cover.
The surfaces themselves live in @enode/ui/feedback (toast, InlineAlert,
BlockingErrorDialog). The toast helper exposes
toast.success | error | warning | info(message, opts).
The core principle
A toast confirms an outcome the user cannot otherwise see. That is the whole test. If the result is already visible on screen, a confirmation toast is noise; if it isn't, the toast is doing real work.
The trigger for a success toast is therefore not "a backend call happened". It is "the user has no other evidence the action succeeded". Toasting every mutation trains users to ignore toasts, so the ones that matter (a deferred job, a silent rollback) stop registering.
Success: when to toast, when not
Do not toast when the effect is self-evident — the mutation is the feedback:
- The created row appears in the table.
- The edited value updates in place.
- The deleted item disappears.
- The drawer closes, revealing the change behind it.
Toast when the outcome is invisible, deferred, or off-screen:
- Deferred / async jobs — the result arrives later, not on screen now. The
data export is emailed when ready; a roster upload starts a background job. The
user clicked and nothing visibly changed, so confirm it
(
toast.success("Export started — we'll email you the file when it's ready.")). - Consequential / irreversible actions, where reassurance has value even if the row vanishes — "User deleted", "Invite sent".
- Off-screen effects — the change lives on a different page or entity than the one the user is looking at.
Keeping success toasts rare is what makes them register.
Failure: route through the error engine, never hand-rolled
Do not build a one-off error toast at the call site. Failures go through the
presenter (presentError(err, t, opts) / useErrorPresenter().present(...)),
which classifies the error and routes it to the right surface by disposition
(see error handling). A "negative toast" is specifically
the graceful disposition — a recoverable, non-blocking, non-field-specific
failure. The other dispositions are deliberately not toasts:
| Failure shape | Surface (chosen by the engine, not the call site) |
|---|---|
| Recoverable, non-blocking | toast (graceful) |
| Form / single-field validation | InlineAlert beside the field — a toast can't point at it |
| Unrecoverable / critical write | BlockingErrorDialog (blocking) |
| Auth / resumable redirect | a guard navigates or a resolution dialog resumes the request (redirect) |
So at the call site you decide whether to present the error at all, not which surface — the disposition decides the surface.
Optimistic UI flips the rule
When you optimistically update local state and then persist (e.g. the planner's drag-to-reschedule, inline cell edits):
- Success → no toast. The optimistic change already showed the result.
- Failure → toast required. The user sees a confusing rollback — the chip jumps back, the value reverts — and without a toast has no idea why. This is the one place a failure toast is non-negotiable and a success toast is redundant.
Guardrails
- One outcome, one surface. Never toast and
InlineAlertthe same failure. - Batch bulk actions. "Deleted 12 users" is one toast, not twelve.
- Don't toast reads or pure navigation. Loading skeletons and the rendered result cover those.
- All toast copy goes through
t(...)— see i18n. Toasts are user-facing prose.
The rule in one line
Toast to confirm an outcome the UI doesn't already make visible — deferred jobs, consequential/irreversible actions, and optimistic-update rollbacks — and route every failure through the error presenter (where "toast" means the graceful, recoverable disposition). Don't toast actions whose result is self-evident on screen.