Patterns
The Idempotent Bulk Action Pattern
By Flora May dela Cruz
How to design a one-time action (upgrade, migrate, archive, enroll) that's surfaced from six different places without letting the user run it twice. The cross-surface enforcement table everyone forgets.
What this is
A small, specific interaction pattern: an action that should run exactly once per item, but is exposed from many entry points across the product. Upgrade to AI. Migrate to v2. Enroll in beta. Archive. Mark verified. Apply baseline policy. The shape is always the same: from one entry point it’s trivial; from six entry points it’s where teams ship double-runs, ghost states, and bug reports that all start with “I think I already did this.”
This pattern is the answer. It’s not a playbook — it’s a single page of interaction rules you apply per entry point and a small state model that’s the same regardless of stack.
When to use it
- The action is destructive-when-repeated: running it twice either doesn’t help, costs the user something, or creates duplicate records
- The action is surfaced from multiple places: a toolbar, a card footer, a row inline action, a recommendation card, a side drawer, a detail page
- The action involves a multi-step dialog (confirm → loading → success)
- The action can be triggered against single items, multiple items, or parents that aggregate children
If the action is idempotent at the backend (re-running it is a safe no-op), the visual rules still apply — leaving the trigger enabled implies pending work. The pattern is about what the user is told, not just what the system does.
The pattern
1. State model
One map. Keyed by item ID. Value is whatever post-action information you need to display (new score, new tier, completion timestamp).
type ActionMap = Map<string, ActionResult>
Storage:
- Prototype / session-scoped: sessionStorage, keyed by something like
<feature>-completed. - Production: a server source of truth, with the map hydrated from API on mount.
A derived helper for parents that aggregate children:
getParentInfo(parent) => {
hasAny: boolean // at least one child is done
hasAll: boolean // every child is done
partial: boolean // some but not all
rollup: <whatever aggregate value the parent displays>
}
That’s the entire model. Every entry point reads from this map; no entry point maintains its own state.
2. The three labels
Every trigger, on every surface, uses exactly one of these labels — never anything else.
| State | Label | Enabled? |
|---|---|---|
| Nothing in the target set is done | " | Yes |
| Some are done, some aren’t | ” | Yes — operates on the not-done subset only |
| Everything in the target set is done | ”Already | No |
For “Upgrade”: Upgrade / Upgrade remaining / Already upgraded. For “Migrate”: Migrate / Migrate remaining / Already migrated. For “Enroll”: Enroll / Enroll remaining / Already enrolled.
The three-label rule is the entire UX of the pattern. It’s also where almost every implementation fails — by surfacing four or five different labels across surfaces (“Upgrade”, “Upgrade now”, “Upgrade selected”, “Re-upgrade”, “Upgrade again”).
3. The cross-surface enforcement table
For each entry point, name the disabled rule and the relabel rule. The act of filling in this table is how you catch the surface that quietly accepts double-runs.
| Entry point | Disabled when | Relabeled when |
|---|---|---|
| Toolbar primary button (selection-based) | All selected items are done | Some selected items are done |
| Card footer button (single item) | That item is done | n/a (single item) |
| Card footer button (parent of children) | All children are done | Some children are done |
| List/grid row inline action (single item) | That item is done | n/a |
| List/grid row inline action (parent row) | All children of that row are done | n/a — disable only |
| Recommendation card | Hidden entirely when no targets remain | Targets filtered to not-done only |
| Side drawer footer button | All items in drawer scope are done | Some items in drawer scope are done |
The pattern is the same in every row: the trigger is gated by the done state of its target set, and the label tells the user which state the trigger is in. You write this table once per action. The product reviews use it for QA.
4. The pre-filter rule
Every entry point that opens the action dialog passes a target list. That list must be pre-filtered to exclude already-done items before it reaches the dialog. The dialog never has to know what’s done — it always operates on a list of “things to do.” This pushes the filtering responsibility to the entry point where the context is, not to the dialog where the context is gone.
A side benefit: the dialog’s count and table never show items the action won’t actually touch, eliminating the most common “wait, why is that in the list” reaction in user testing.
5. The success-clears-selection rule
When the action’s success callback fires, clear any selection that triggered it. This is the same rule as in the Sticky View State Playbook, restated here because it’s the most important rule for actions surfaced from a multi-select toolbar:
- Clear on
onSuccess, not on dialog close. - Don’t clear on cancel.
- Don’t clear on dismiss with the ✕.
Leaving items selected after success implies pending work. Clearing on cancel loses the user’s work. Clear on onSuccess is the only correct timing.
6. The dialog flow
Three phases, named precisely so engineering can implement consistently:
Confirm. Shows the impact: count of items, summary of before/after, table when there are multiple items. Primary button label matches the toolbar label ("
Loading. A spinner with the action name as gerund. Fixed duration (1.5s is typical for prototypes; production uses the real call). The success callback fires at the end of loading, not the end of the success phase — this is what allows selection to clear visually while the success screen is still showing.
Success. A checkmark, ”onClose which is also where selection clearing is wired.
7. Visual feedback on the item after success
The pattern almost always benefits from a brief, contained visual celebration on the item that just got updated. Two specific rules keep this from getting cute:
- A pulse, not a sustained glow. A 0.9-second ring that pulses out from the item border and ends in the natural state. Long-lived “this is upgraded forever” highlights become noise — the indicator is the new score / new badge / new state, not the lingering glow.
- A small permanent marker. A 12px icon (sparkle, checkmark, badge) placed inline with the item’s name or in a fixed corner of the card. This is what the user sees from then on. The pulse is the moment of change; the marker is the lasting state.
Reusable template
Drop this into a design doc per action.
# <Action> — cross-surface specification
## State model
- Done-map key: `<feature>-completed`
- Stored value per item: { <fields the UI displays after action> }
- Parent rollup rule: <one sentence>
## Three labels
- Default: "<Action>"
- Mixed: "<Action> remaining"
- All done: "Already <verb past tense>" (disabled)
## Entry points
| Entry point | Disabled when | Relabeled when |
|---|---|---|
| ... | ... | ... |
## Pre-filter rule
Every entry point pre-filters to not-done items before opening the dialog.
## Dialog phases
1. Confirm — primary label matches the trigger label
2. Loading — 1.5s spinner, success callback fires at the end of this phase
3. Success — checkmark, auto-dismiss 3s, calls onClose which clears selection
## Visual feedback on success
- 0.9s pulse ring on the affected item (one-shot, ends at natural state)
- 12px <marker icon> inline with the item name, persistent
- Updated value (score / tier / badge) replaces the old value
Common failure patterns
- Per-surface state. Each entry point tracks its own “done” set. They drift; the user sees one card with the sparkle and another without.
- Re-runnable trigger. Action stays enabled after completion because nobody owned the disabling rule for that surface.
- Four labels instead of three. “Upgrade,” “Upgrade now,” “Re-upgrade,” “Upgrade again” all in the same product. Pick three; the rules above tell you which.
- Dialog filters internally. Dialog receives all selected items including done ones, then quietly drops them. User sees a count that doesn’t match the table. Filter at the entry point.
- Selection persists after success. Implies there’s still work. Clear on
onSuccess. - Selection clears on cancel. User loses their work. Don’t clear on cancel.
- Sustained glow. The upgraded item glows forever. The indicator should be the new value, not a permanent highlight. Use a one-shot pulse + a small persistent marker.
- No partial-parent rule. The parent (category, group, project) shows nothing changed even though three of its five children are done. Use a parent-rollup derivation that reflects the partial state.
Generalized example
A fictional vendor enrollment workflow. Vendors can be enrolled into a verification program. The action is surfaced from: a recommendation card on the dashboard, a toolbar on the vendor list, an inline icon in each vendor row, a footer button on each vendor card, and a footer button in the bulk-actions side drawer. Five surfaces.
The enrollment map is keyed by vendor ID. The three labels appear everywhere: Enroll / Enroll remaining / Already enrolled. The vendor list’s toolbar button reads “Enroll remaining” when the user has selected nine vendors and three are already enrolled — and the dialog opens with six items, not nine. After confirmation, the six pulse, get a small check-badge next to their name, and the selection clears at the moment the success screen appears. The recommendation card disappears when zero vendors remain to enroll. Open a vendor’s detail page and the “Enroll” button reads “Already enrolled” with the same disabled style as everywhere else.
That sequence — same three labels, pre-filtered targets at every surface, success clears selection, pulse plus persistent marker — is the entire pattern.
Companion artifacts
- The Sticky View State Playbook — the selection-clearing rule applies identically
- The Accessibility Compliance Baseline Playbook — the disabled state and the persistent marker each need a non-color companion (text and an icon, respectively)
Public-safe review (verified before publish)
- No employer or client product names or codenames
- No customer data, metrics, or thresholds drawn from real systems
- No real component-library or package names
- No real route paths or class hashes
- All examples invented; the threshold numbers (1.5s, 0.9s, 3s, 12px) are illustrative defaults
- A peer outside any employer could read this and learn nothing proprietary
Take this playbook with you
Drop your email to copy the markdown or download the file. One email unlocks every playbook in the Toybox.
No spam. Occasional notes on new playbooks. Unsubscribe in one click.