Multi-select with context-aware bulk actions
I built multi-select for the mail reader UI and then almost shipped a bad version of the bulk-archive button. The fix was making the button's behavior depend on what was selected, instead of always doing the same thing.
The multi-select itself
Standard pattern, three nice touches:
- Checkboxes appear on hover, persist once anything is selected.
- Click anywhere on a row to toggle selection when items are already selected (so you don't accidentally open a message mid-select).
- Shift-click for range selection.
- Keyboard:
xtoggles the current row,Escclears selection,j/kmove the cursor. - "Select all visible" checkbox in the header.
The bulk action bar appears at the top when there's a selection, with buttons for Read, Unread, Star, Archive, Spam, Trash. Bulk operations are batched 100 at a time on the backend.
The bad version of the Archive button
First pass: the Archive button always archived. Click it, every
selected message gets the archived flag set.
Then I tried using it from inside the Archive folder. I'd selected a bunch of messages in Archive that I wanted to pull back into the inbox. Clicked Archive. Nothing visibly changed, because they were already archived.
Hit Spam. They went to spam. Hit Unread. They became unread. The Archive button was the only one that had no inverse, because the underlying operation only had one direction.
Context-aware behavior
The fix: the button looks at what's selected and chooses the action.
- Selection contains only archived items → button label becomes "Move to Inbox," click un-archives.
- Selection contains only unarchived items → button label is "Archive," click archives.
- Mixed selection → button is "Archive" (archives whatever isn't yet archived). The reasoning: when you've mixed-selected, you probably want them all in one place; archive is the "put it away" direction.
Same trick works for Star/Unstar, Read/Unread, Spam/Not-spam. The button is now a verb that adapts to the noun.
Feedback that doesn't lie
After clicking, the button shows explicit feedback:
- "47 updated" — all good
- "45 ok, 2 failed" — partial
- "47 failed" — all-bad
Selection auto-clears on success after about a second so the next selection feels fresh. On failure it stays visible for a few seconds so I can either retry or scroll to see what went wrong.
The reason for the partial-failure case: bulk operations batch on the server side, and a batch can have individual failures inside it (Dynamo conditional checks, optimistic concurrency, etc.). Hiding those silently is worse than showing the count.
What I'd do differently
I'd have written the "what does this button do given the selection
state" logic as a pure function from selection → action upfront,
instead of bolting it on. The current code has it inline in the
React component, which is fine but means the logic isn't tested
independently of the UI. A pure function returning
{label, action, severity} would be trivial to unit-test and
would make the "what should this say?" question a one-liner.
Also: the Trash button still has the "always trashes" problem in some corners — selecting items already in Trash, the button still says Trash and clicking it does nothing useful. The right action would be "Restore" when selection is in Trash. I'll fix that the next time I'm in there.