react-call v2

Progress toast

A singleton toast that updates itself as work progresses. Uses upsert() so consecutive calls mutate the same instance.

Upsert

The Callable

declared once, mounted in the React tree
Toast.tsx
import { createCallable } from 'react-call'
interface Props {
message: string
percent?: number
}
export const Toast = createCallable<Props, void>(
({ call, message, percent }) => (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed bottom-6 right-6 z-50"
>
<div className="pointer-events-auto min-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-4 shadow-2xl">
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-[var(--color-fg)]">{message}</p>
<button
type="button"
onClick={() => call.end()}
aria-label="Dismiss"
className="-mr-1 inline-flex h-7 w-7 items-center justify-center rounded-md text-base leading-none text-[var(--color-fg-subtle)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-fg)]"
>
×
</button>
</div>
{typeof percent === 'number' && (
<div className="mt-3 h-1 overflow-hidden rounded-full bg-[var(--color-bg-muted)]">
<div
className="h-full bg-[var(--color-accent)] transition-all duration-200"
style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
/>
</div>
)}
</div>
</div>
),
)
Toast.displayName = 'Toast'

The Root

mounted in your app tree, once
App.tsx
import { Toast } from './Toast'
import { DownloadButton } from './DownloadButton'
export default function App() {
return (
<>
<Toast />
<DownloadButton />
</>
)
}

The caller

anywhere in your app, imperative
DownloadButton.tsx
import { useState } from 'react'
import { Toast } from './Toast'
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms))
export const DownloadButton = () => {
const [running, setRunning] = useState(false)
const handleClick = async () => {
setRunning(true)
// Capture the singleton promise so the loop can abort if the user
// dismisses the toast via the × button mid-progress. Without this,
// each upsert after a dismiss would create a brand-new instance and
// the button would stay disabled until the loop finishes.
let cancelled = false
const session = Toast.upsert({ message: 'Starting download…', percent: 0 })
session.then(() => {
cancelled = true
})
for (let i = 10; i <= 100; i += 10) {
await sleep(150)
if (cancelled) {
setRunning(false)
return
}
Toast.upsert({ message: `Downloading… ${i}%`, percent: i })
}
Toast.upsert({ message: 'Done!', percent: 100 })
await sleep(800)
Toast.end()
setRunning(false)
}
return (
<button
type="button"
disabled={running}
onClick={handleClick}
className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)] disabled:opacity-50"
>
{running ? 'Downloading…' : 'Start download'}
</button>
)
}

Why upsert and not call

Each Toast.upsert(props) either creates the singleton or updates its props. The returned promise is the same reference across the lifetime of the singleton — you can await Toast.upsert(…) once and your code resumes when the toast finally closes, even if its message changes a dozen times in between.

Because upsert keeps a single instance, every Toast.upsert(…) — wherever it’s called from — targets that same toast: a second caller updates the existing one instead of opening another. That’s exactly what a progress indicator wants — there should only ever be one. When each event should get its own toast, reach for .call() instead and the Root stacks them (that’s error-banner).

When to reach for it

  • Progress indicators where the message updates as work advances.
  • A “saving…” → “saved” status pill.
  • A connection state badge that may flip many times during a session.

Related examples