Search docs

Jump to any table example

Discord—

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.json

Sync, archive, or delete a source. S3 fails its first sync on purpose.

SourceStatusLast syncedActions

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.

src/components/async-actions/action-buttons.tsx
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.

src/components/async-actions/action-buttons.tsx
useEffect(() => {
if (!syncing) return
icon.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.

src/components/async-actions/data-table.tsx
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.

src/components/async-actions/data-table.tsx
setLeaving((prev) => new Set(prev).add(id))
later(() => {
setSources((prev) => prev.filter((s) => s.id !== id))
}, EXIT_MS)