Async Row Actions
Row actions that show what is actually happening: the sync icon spins while a request is pending, failures turn into Retry, archive offers Undo, and delete asks for a second click. The animated icons act as feedback, not decoration.
$ npx shadcn add https://www.shad-table.dev/r/async-actions-table.jsonSync, archive, or delete a source. S3 fails its first sync on purpose.
| Source | Status | Last synced | Actions |
|---|---|---|---|
Postgres — production Database | Synced | 3 min ago | |
Stripe Payments | Synced | 12 min ago | |
HubSpot CRM | Failed· Token expired | 1 h ago | |
S3 — event logs Storage | Synced | 41 min ago | |
Segment Analytics | Synced | 7 min ago | |
Zendesk Support | Synced | 3 h ago |
How it works
1.The button owns the animation, not the icon
Each animated icon plays on its own hover, which would stop a spin the moment the pointer leaves mid-request. So the icon gets pointer-events-none and the button drives it through the icon's imperative handle: hover and focus start it, and leaving only stops it when nothing is pending.
function hoverHandlers(ref, locked = false) {const start = () => ref.current?.startAnimation()const stop = () => {if (!locked) ref.current?.stopAnimation()}return { onMouseEnter: start, onFocus: start, onMouseLeave: stop, onBlur: stop }}
2.A pending request keeps the icon spinning
The refresh icon spins once per start, so while a row is syncing an effect re-triggers it every 900 ms and stops it when the status changes. The row status, not a separate loading flag, is what drives the motion.
useEffect(() => {if (!syncing) returnicon.current?.startAnimation()const id = window.setInterval(() => icon.current?.startAnimation(), 900)return () => {window.clearInterval(id)icon.current?.stopAnimation()}}, [syncing])
3.Reversible actions get Undo, destructive ones get a confirm
Archive removes the row right away and offers Undo for 6 seconds, putting the row back where it was. Delete cannot be undone, so the first click turns the button into "Confirm" for 3 seconds; Escape or clicking away cancels it.
function requestDelete(id: string) {setConfirmingId(id)window.clearTimeout(confirmTimer.current)confirmTimer.current = later(() => setConfirmingId(null), CONFIRM_MS)}
4.Rows fade out before they are removed
Removing a row instantly would cut off the archive or trash animation. The row is first marked as leaving, fades to transparent, and is removed after 450 ms, so the icon's motion is the last thing the user sees.
setLeaving((prev) => new Set(prev).add(id))later(() => {setSources((prev) => prev.filter((s) => s.id !== id))}, EXIT_MS)