-
Notifications
You must be signed in to change notification settings - Fork 10
command pattern
Use a Command for any user-triggered mutation that needs loading state, confirmation, toasts, or side effects after the write. The Command encapsulates the whole procedure from start to finish — confirmation, mutation, success-side state changes, navigation.
Companions:
- command-input-validation.md — gate the trigger at the caller; command body assumes valid input.
- list-layout.md — per-item commands inside the body slot.
Commands are not designed to be composable. They model a single user intent end-to-end. If you find yourself wanting to chain .handle() calls, extract the shared step as an Effect / function / mutation and yield* it inside each command body. Don't wrap one command in another.
// BAD — composing commands
const closeAndPrint = () => { cmdClose.handle(); cmdPrint.handle() }
// GOOD — extract the shared work as Effects, compose them inside one command
const closeAndPrint = client.Close.fn(
function*(input) {
yield* client.Close.mutate(input)
yield* printLabelEffect(input.id)
},
Command.withDefaultToast()
)-
.handle()is fire-and-forget. Returns aRuntimeFiber, not aPromise. Neverawaitit, never.then()it. -
No wrapper functions around
.handle(). The button event binds to.handle()directly, or useCommandButton. Wrappers lose loading state and produce "assumed success" patterns (state reset before mutation succeeds). For the narrow set of cases where a wrapper is unavoidable, see § When.handle()in a wrapper is acceptable. -
Side effects live inside the command body. Closing dialogs, resetting refs, navigation — all
yield*'d after the mutation. They only run on success. -
CommandButtonis the default trigger. Manual<v-btn>with:loading="cmd.waiting" :disabled="cmd.blocked" @click="cmd.handle(…)"is a last-resort fallback (form submits, non-button elements). -
No
run(), noPromise, noasync/await. ThinkEffect.gen/Effect.fnandyield*.
-
await cmd.handle(...)— Fiber, not Promise. -
.then()anywhere in command code. -
run(client.X(...))— should be a command. -
.handle()that is not the only statement in an event chain. 99% of the time it's the first and last call. State mutations after it = assumed success. - State mutations outside the command body (closing dialogs, resetting refs in the click handler).
-
<v-btn>with:loading="cmd.waiting"/:disabled="cmd.blocked"/@click="cmd.handle(...)"that could be<CommandButton>. - Hardcoded text between
<CommandButton>…</CommandButton>when an intl label exists (self-close:<CommandButton … />).
| Use | Form | Why |
|---|---|---|
| Side effects, confirmation, post-mutation logic | .fn(function*() { ... }, Command.withDefaultToast()) |
Generator reads top-to-bottom; sync code doesn't need Effect.sync(...) wrapping. |
| Simple passthrough — no custom logic | .mutate.wrap(Command.withDefaultToast()) |
One-liner, no body needed. |
// GOOD: passthrough
const scanAndPrint = itemsClient.ScanAndPrintItem.mutate.wrap(Command.withDefaultToast())
// GOOD: side effects
const reset = meClient.Reset.fn(
function*() {
yield* meClient.Reset.mutate
window.location.reload()
},
Command.withDefaultToast()
)Prefer generators over combinator chains (Effect.tap, Effect.andThen, Effect.flatMap). Generators are linear and don't need Effect.sync(...) for plain assignments.
Commands returned by .fn() / .mutate.wrap() expose:
{
handle: (arg: I) => RuntimeFiber // fire-and-forget
waiting: ComputedRef<boolean> // this command is executing → :loading
blocked: ComputedRef<boolean> // this command OR a related one is executing → :disabled
result: ComputedRef<Result<A, E>> // last execution outcome
allowed: ComputedRef<boolean> // gate via `allowed()` option; CommandButton hides when false
action: ComputedRef<string> // i18n action label
label: ComputedRef<string> // i18n button label
}-
waiting→ this command is running. Use for:loading(spinner on the clicked button only). -
blocked→ this command or any related command is running. Use for:disabled(block overlapping actions).
Share blocking across related commands via blockKey / waitKey options. CommandButton already wires waiting/blocked — don't re-bind them manually.
{
state?: () => State // snapshot at invocation; injected into i18n vars
waitKey?: (id: string, ...args) => string // share waiting state across commands
blockKey?: (id: string, ...args) => string // share disabled state across commands
allowed?: () => boolean // permission gate; CommandButton hides when false
i18nCustomKey?: string // override the i18n key
}state is captured at .handle() time and frozen for the duration. It's also surfaced in i18n messages as template variables, so a single action key can render different toasts depending on context:
"action.ShipList.UpdateStackability":
"{mode, select, stack {Stack} resetAll {Reset all} other {Update stackability}}"waitKey/blockKey with dynamic keys enable per-item state in lists (see Command.family() below).
Self-closing, label-from-intl is the default form:
<!-- GOOD -->
<CommandButton :command="myCommand" :input="inputValue" color="primary" />
<!-- BAD: hardcoded text overrides the intl label -->
<CommandButton :command="myCommand" :input="inputValue">Pack</CommandButton>
<!-- BAD: re-binding what CommandButton already handles -->
<CommandButton :command="cmd" :disabled="cmd.blocked" :loading="cmd.waiting" />
<!-- GOOD: :disabled for *additional* conditions only -->
<CommandButton :command="importFiles" :input="files" :disabled="files.length === 0" />- Accepts all
v-btnprops (variant,color,size,block). - Icon-only: pass
empty.<CommandButton :command="updateStackability" :input="{ shipmentId, action }" :icon="mdiSwapVertical" size="x-small" empty />
- Optional input (command may take args or none): pass
:input="undefined"and make the generator parameter optional.
CommandButton uses aria-disabled, not the native disabled attribute. Clicks on a disabled button are silently ignored at the component level (intentional accessibility choice).
If allowed: () => … is set in command options, CommandButton renders nothing when it returns false. No v-if needed in the template.
@success="..." on CommandButton is silently ignored. Move post-mutation side effects inside the command body — they only run on success there anyway, which is what you usually wanted.
// GOOD — only reached when the mutation succeeded
const cmd = client.Action.fn(
function*(input) {
yield* client.Action.mutate(input)
dialogOpen.value = false
},
Command.withDefaultToast()
)Form submit buttons or non-button elements (cards, rows) sometimes need manual binding. Always split :loading and :disabled:
<v-btn :loading="cmd.waiting" :disabled="cmd.blocked" @click="cmd.handle({ input })">
{{ cmd.label }}
</v-btn>For non-button clickable containers, conditional v-on is correct — it controls cursor (pointer vs default), which :disabled alone doesn't:
<v-card :disabled="cmd.blocked" v-on="canClick ? { click: () => cmd.handle(args) } : {}" />Prefer Command.confirmOrInterrupt() — uses the command's i18n action name as the default title:
const pause = cartClient.PausePacking.fn(
function*(input) {
yield* Command.confirmOrInterrupt() // uses i18n action label
yield* cartClient.PausePacking.mutate(input)
},
Command.withDefaultToast()
)
const deleteUser = userClient.DeleteUser.fn(
function*() {
yield* Command.confirmOrInterrupt("Really delete user?") // custom message
yield* userClient.DeleteUser.mutate({ userId })
},
Command.withDefaultToast()
)Custom-button dialogs use alertAddEffectOrInterrupt:
yield* alertAddEffectOrInterrupt(
{ title: "Delete?", body: "This cannot be undone." },
{ name: "Delete", color: "red", returnValue: true },
{ name: "Cancel", color: "grey", returnValue: false }
)confirmDialogOrInterrupt(body, title) is the legacy two-arg form — still works, but prefer Command.confirmOrInterrupt for new code.
Effect.catchTag for specific errors:
const toggle = Command.fn("toggleFavorite", {
state: () => ({ isFavorite: isFavorite.value })
})(function*(_, { state }) {
if (state.isFavorite) {
yield* client.Remove({ tickerId }).pipe(
Effect.catchTag("NotFoundError", () => Effect.void)
)
} else {
yield* client.Add({ tickerId })
}
})Effect services — Router.push, I18n.formatMessage, Toast, Command — are available natively. No hook imports, no Effect.promise wrapping:
const claim = pickListClient.ClaimList.fn(
function*() {
yield* pickListClient.ClaimList.mutate
yield* Router.push({ name: "my-list" })
},
Command.withDefaultToast()
)When you render commands in a v-for loop and need independent loading/disabled state per item, use Command.family(). It memoises a command instance per key (WeakMap with structural arg compare; GC'd when unreferenced).
const deleteUser = Command.family((userId: UserId) =>
Command.fn(deleteUserByIdMutation)(
function*() {
yield* Command.confirmOrInterrupt("Really delete user?")
yield* deleteUserByIdMutation({ userId })
},
Command.withDefaultToast()
)
)<CommandButton :command="deleteUser(item.id)" />Without family(), every row shares the same waiting/blocked — clicking one row spins all rows.
Replace previous toasts instead of stacking them — useful for inline-edit fields that fire repeatedly:
Command.fn(updateMutation, { waitKey: (id) => `${id}.${item}.name` })(
function*() { yield* updateMutation(item, { name: newName }) },
Command.withDefaultToast({ stableToastId: (id) => `${id}.${item}.name` })
)-
stableToastId: true— uses the command id. -
stableToastId: string— fixed id. -
stableToastId: (id, ...args) => string— dynamic.
Command.withDefaultToast({
stableToastId?: string | true | ((id, ...args) => string | undefined)
errorRenderer?: (error, action, ...args) => string | undefined
onWaiting?: null | string | ((id, ...args) => string | null) // null suppresses waiting toast
onSuccess?: null | string | ((result, action, ...args) => string | null) // null suppresses success
})Defaults (in frontend/composables/intl.ts):
-
handle.waiting→"Running..." -
handle.success→"{action} succeeded" -
handle.with_errors→"{action} failed" -
handle.confirmation→"Confirm: {action}?"
When you don't have a client.Action.fn(...) (e.g. composing mutations from hooks), pass the mutation as the first arg so the command inherits its id/type context:
const deleteUser = Command.fn(deleteUserByIdMutation)(
function*() {
yield* Command.confirmOrInterrupt("Are you sure?")
yield* deleteUserByIdMutation({ userId })
},
Command.withDefaultToast()
)For permission-gated commands, use useAllowed().allowed(...) as a separate v-if:
const retryLabelIsAllowed = useAllowed().allowed(OverviewRsc.RetryLabel)
const retryLabel = overviewClient.RetryLabel.fn(
function*(input) { yield* overviewClient.RetryLabel.mutate(input) },
Command.withDefaultToast()
)<CommandButton v-if="retryLabelIsAllowed" :command="retryLabel" :input="…" />The legacy useAllowed().addIsAllowedToMutation(...) wrapper around useAndHandleMutation is retired.
The rule "no wrapper functions around .handle()" has narrow, well-defined exceptions:
-
Drag-and-drop handlers. Clearing drag refs via
onDragEnd()before.handle()is plumbing, not assumed-success state. -
Reactive watchers (
watch(...)). No click event to bind aCommandButtonto. -
Debounced operations.
_.debounce(() => cmd.handle(input), 500)— can't useCommandButton. - Called from inside another command body. Almost always wrong — extract the shared step as an Effect instead (see § Core rule). Acceptable only when the inner call truly is its own user-intent that happens to fire from inside another command.
-
Form submit handlers. Bound to
<v-form @submit.prevent="onSubmit">because the event source is the form, not a button.
const onSubmit = () => {
if (!isFormValid.value) return // local validation guard OK
createPortfolio.handle({ input: formStore.value })
}The wrappers that aren't acceptable are the ones that exist only to mutate refs around .handle() (close dialog, reset form, build input from refs). Push that logic into the command body.
The rule lives in command-input-validation.md: a gating computed types the command input, the trigger is hidden/disabled when input is incomplete, and the body works with the narrow validated type.
Two genuine reasons to read a ref inside the body instead:
-
Page-stable refs (
props.order.id,effectiveCarrier). Stable for the page lifetime; the user isn't filling them in. Keep the:inputpayload focused on the user's actual choice. -
Non-UI triggers — scan handlers, SSE events, job retries — where the caller can't pre-validate. Surface a typed
InvalidStateErrorinstead of a silentreturn.
Reading user-filled form refs in the body (selectedArticle.value, countNotThere.value) is not in this list. Push the gating into the child component and have it emit the full payload — see the "Dialogs and child components: emit the full payload" section of command-input-validation.md.
Use typeof client.Action.Input:
const addOrderToCart = pickListClient.AddOrderToCart.fn(
function*(input: typeof pickListClient.AddOrderToCart.Input) {
yield* pickListClient.AddOrderToCart.mutate(input)
yield* Router.push({ name: "commission-my-list" })
},
Command.withDefaultToast()
)Omit the parameter; call .handle() with no args (not .handle({})):
const claim = pickListClient.ClaimList.fn(
function*() {
yield* pickListClient.ClaimList.mutate
yield* Router.push({ name: "my-list" })
},
Command.withDefaultToast()
)
// template: @click="claim.handle()"When the input is page-stable (a prop), bake it in and let .handle() take no args:
const retryLabel = deliveryNoteClient.RetryLabel.fn(
function*() {
yield* deliveryNoteClient.RetryLabel.mutate({ id: props.item.deliveryNoteId })
yield* props.getPickList
},
Command.withDefaultToast()
)
// template: @click="retryLabel.handle()"This is the "page-stable ref" exception above, not "read form state in body".
yield* in a for loop replaces the old await run(exec(...)) chain:
const pickedCmd = pickListClient.PickedPickList.fn(
function*(position: number) {
if (pickAll.value) {
for (const a of latestPickList.value.tasks) {
yield* pickListClient.PickedPickList.mutate({ articleId: a.articleId, position: S.NonNegativeInt(position) })
}
pickAll.value = false
} else {
yield* pickListClient.PickedPickList.mutate({ articleId: selectedArticle.value!.articleId, position: S.NonNegativeInt(position) })
}
showCartDialog.value = false
},
Command.withDefaultToast()
)For long-running mutations (imports, bulk re-label, mass re-pick) declare the request as a stream command on the server and use Command.withDefaultToastStream on the client. The toast updates in place with the progress; the mutation completes when the stream ends.
See streams-and-progress.md for the full pattern, server / client / view-schema details, and the operationProgress helper.
Quick version:
// resource
export class RetryLabel extends Req.Command<RetryLabel>()(
"RetryLabel",
{},
{ stream: true, success: OperationProgress },
(queryKey) => [{ filters: { queryKey } }, { filters: { queryKey: makeQueryKey(List) } }]
) {}
// frontend
const retryLabel = overviewClient.RetryLabel.mutate.wrap()(
Command.withDefaultToastStream({ progress: operationProgress })
)<CommandButton :command="retryLabel" />Configure mutation cache invalidation on the Req.Command definition in the
resource file. Do not pass query invalidation as the second argument to
clientFor() at a page/component call site. A mutation's data dependencies are
part of the API contract, not a local UI detail; every consumer of the command
should get the same cache behavior.
import { GetMe } from "#resources/Me"
import { List as PickCartsList } from "./PickCarts.Queries.ts"
const invalidatePicking = (queryKey: readonly string[]) => [
queryKey,
GetMe,
GetStats,
PickCartsList
]
export class Full extends Req.Command<Full>()(
"Full",
{},
{ allowRoles: ["user"] },
invalidatePicking
) {}Use real query request classes in the invalidation list whenever possible. If a
command in resource A must invalidate a query in resource B, import the query
class from B's query-only module instead of configuring it in clientFor().
import { List as DropshippingPickList } from "../../Dropshipping/resources/PickList.Queries.ts"
const invalidatePickLists = (queryKey: readonly string[]) => [
queryKey,
GetMe,
GetStats,
DropshippingPickList
]When a command changes the current user's claimed resource, invalidate GetMe
from the command's resource definition. Do not manually assign store.user in
the page to predict the new user state.
export class Release extends Req.Command<Release>()(
"Release",
{ cartId: OneOrMoreCarts },
{ allowRoles: ["user"] },
(queryKey) => [queryKey, GetMe, GetStats]
) {}The client mutation invalidates/refetches affected queries before the command
body continues to success-side effects such as navigation. Route guards and
headers should observe the refreshed GetMe cache/store instead of a local
optimistic write.
When adding a new API action (request class), add a translation in frontend/composables/intl.ts for each supported locale. Without it, toasts show the raw key (e.g. ShipList.ReprintLabel succeeded).
Key format: "action.{moduleName}.{ActionClassName}". moduleName comes from the resource's meta.moduleName.
Most actions need a short label for the button and a longer one for the toast. Use ICU _isLabel select:
"action.PickList.AbortPickList":
"{_isLabel, select, true {Abort} other {Abort pick list}}",
// same text for both → plain string is fine
"action.PickList.MarkOutOfStock": "Mark out of stock",
// combine with other ICU vars
"action.Overview.ChangeBlocked":
"{blocked, select, true {Block} other {Unblock}} item",The command system sets _isLabel = true when rendering as a button label, false for toasts. Use the select form when:
- The toast needs more context than the button (short button label → longer toast).
- Different verb form (
"Import"button →"Importing"toast). - The button label would be too long.
// BEFORE
const [loading, exec] = legacy.useAndHandleMutation(client.Action, "Action")
// run(exec({ args }))
// AFTER — simple passthrough
const action = client.Action.mutate.wrap(Command.withDefaultToast())
// action.handle({ args })
// AFTER — with side effects
const action = client.Action.fn(
function*(input: typeof client.Action.Input) {
yield* client.Action.mutate(input)
// side effects
},
Command.withDefaultToast()
)// BEFORE
const cancelList = pickListClient.AbortList.mutate.wrap(
(mutate) => confirmDialogOrInterrupt("Are you sure?", "Abort?").pipe(
Effect.andThen(mutate),
Effect.andThen(() => Router.push({ name: "commission" }))
),
Command.withDefaultToast()
)
// AFTER — generator reads top-to-bottom
const cancelList = pickListClient.AbortList.fn(
function*() {
yield* Command.confirmOrInterrupt()
yield* pickListClient.AbortList.mutate
yield* Router.push({ name: "commission" })
},
Command.withDefaultToast()
)andThen no longer auto-flattens in Effect v4. If you still write combinator chains, use flatMap for Effect-returning callbacks, map for plain values. Prefer generators and the question doesn't come up.
// BAD — resets even if mutation failed
const onAdd = () => {
command.handle({ orderId })
selected.value = []
dialogOpen.value = false
}
// GOOD — inside the body, only runs on success
const addOrder = client.AddOrder.fn(
function*(input) {
yield* client.AddOrder.mutate(input)
selected.value = []
dialogOpen.value = false
},
Command.withDefaultToast()
)// BAD — toast fires on the promise, not the Effect's success
void run(updateStackability({ shipmentId, action })).then(() => toast.success("Updated"))
// GOOD — toast is the command's job
const updateStackability = client.UpdateStackability.mutate.wrap(Command.withDefaultToast())
updateStackability.handle({ shipmentId, action }).handle() returns a Fiber. Don't await it. The legacy await run(cmd.handle(...)) still resolves in sequential loops, but the right fix is to pull the loop inside the command body (see § Sequential mutations).
- Index
- Import Rules
- Resource & Controller Layout
- Command Pattern
- Command Input Validation
- Query Shape: List vs Get
- Database Query Guidelines
- List Layout
- Streams & Progress
- Vue Conventions
- E2E State Pattern
- E2E
- E2E Toast Wait Audit
- Flow Documentation
- (project-local — create
flows/when first workflow lands)