react-call v2

Confirm with optional async

One Callable, two callers. Omit mutationFn and submit().orEnd(true) closes instantly with a fallback response; pass one and the async handler decides when to close — the same Confirm serves both.

Mutation flow
→ awaiting click…

The Callable

declared once, mounted in the React tree
Confirm.tsx
import { createCallable } from 'react-call'
import { type MutationFn, useMutationFlow } from 'react-call/mutation-flow'
interface Props {
message: string
// Optional: type it as possibly-undefined to unlock `.orEnd(value)` at
// the callsite. Callers may pass an async handler or skip it entirely.
mutationFn?: MutationFn<boolean>
}
export const Confirm = createCallable<Props, boolean>(
({ call, message, mutationFn }) => {
const submit = useMutationFlow(call, mutationFn)
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
>
<div className="w-full max-w-sm rounded-lg border border-[var(--color-border)] bg-[var(--color-bg)] p-6 shadow-2xl">
<p className="text-base text-[var(--color-fg)]">{message}</p>
<div className="mt-6 flex items-center justify-end gap-3">
<button
type="button"
disabled={submit.pending}
onClick={() => call.end(false)}
className="rounded-md border border-[var(--color-border)] px-4 py-2 text-sm font-medium text-[var(--color-fg-muted)] transition-colors hover:text-[var(--color-fg)] disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
disabled={submit.pending}
// No mutationFn → `.orEnd(true)` closes with the fallback.
// With one → `.orEnd` is a no-op; the handler closes instead.
onClick={() => submit().orEnd(true)}
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"
>
{submit.pending ? 'Working…' : 'Confirm'}
</button>
</div>
</div>
</div>
)
},
)
Confirm.displayName = 'Confirm'

The Root

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

The caller

anywhere in your app, imperative
PublishButton.tsx
import { useState } from 'react'
import { Confirm } from './Confirm'
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms))
export const PublishButton = () => {
const [status, setStatus] = useState<string>('→ awaiting click…')
// No mutationFn: `submit().orEnd(true)` closes the call instantly with
// the fallback response. A plain confirm — no async, no pending state.
const quickConfirm = async () => {
const ok = await Confirm.call({ message: 'Discard your draft?' })
setStatus(ok ? '→ draft discarded' : '→ kept')
}
// With a mutationFn: `.orEnd` becomes a no-op. The handler owns the
// close — pending shows while it runs, then it calls call.end(true).
const confirmAndPublish = async () => {
const ok = await Confirm.call({
message: 'Publish this post now?',
mutationFn: async (call) => {
await sleep(900)
call.end(true)
},
})
setStatus(ok ? '→ published' : '→ cancelled')
}
return (
<div className="flex flex-col items-center gap-3">
<div className="flex gap-3">
<button
type="button"
onClick={quickConfirm}
className="rounded-md border border-[var(--color-border)] px-4 py-2 text-sm font-medium text-[var(--color-fg-muted)] transition-colors hover:text-[var(--color-fg)]"
>
Discard draft
</button>
<button
type="button"
onClick={confirmAndPublish}
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)]"
>
Publish post
</button>
</div>
<span className="font-mono text-xs text-[var(--color-fg-subtle)]">
{status}
</span>
</div>
)
}

One Callable, two close paths

Typing mutationFn? as possibly-undefined changes what the trigger returns: instead of void, submit() hands back a chain object with .orEnd(value). That one chain covers both callers:

  • No handler (the Discard draft button) → submit().orEnd(true) delivers the fallback response and closes the call right away. No async, no pending — a plain confirm.
  • With a handler (the Publish post button) → .orEnd is a no-op. The MutationFn runs (pending flips true), then it calls call.end(true).

The Callable doesn’t branch on which caller it’s serving — useMutationFlow resolves it from whether mutationFn is present at runtime.

See Save form for the required-handler case, where the dialog stays open on a thrown error so the user can retry.

Related examples