react-call v2

Save form with mutation flow

A dialog with an async submit. useMutationFlow tracks pending; on throw, the call stays open so the user can retry without losing their input.

Mutation flow
→ tick "simulate a failed save" to see it stay open

The Callable

declared once, mounted in the React tree
SaveForm.tsx
import { useState } from 'react'
import { createCallable } from 'react-call'
import { type MutationFn, useMutationFlow } from 'react-call/mutation-flow'
interface Props {
initialName?: string
mutationFn: MutationFn<string, { name: string; shouldFail: boolean }>
}
export const SaveForm = createCallable<Props, string>(
({ call, initialName = '', mutationFn }) => {
const [name, setName] = useState(initialName)
const [shouldFail, setShouldFail] = useState(false)
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 font-medium text-[var(--color-fg)]">
Save item
</p>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={submit.pending}
placeholder="Item name"
className="mt-4 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-3 py-2 text-sm text-[var(--color-fg)] focus:border-[var(--color-accent)] focus:outline-none disabled:opacity-50"
/>
<label className="mt-3 flex items-center gap-2 text-xs text-[var(--color-fg-subtle)]">
<input
type="checkbox"
checked={shouldFail}
onChange={(e) => setShouldFail(e.target.checked)}
disabled={submit.pending}
className="accent-[var(--color-accent)]"
/>
Simulate a failed save
</label>
<div className="mt-6 flex items-center justify-end gap-3">
<button
type="button"
disabled={submit.pending}
onClick={() => call.end('')}
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 || !name.trim()}
onClick={() => submit({ name: name.trim(), shouldFail })}
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 ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)
},
)
SaveForm.displayName = 'SaveForm'

The Root

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

The caller

anywhere in your app, imperative
NewItemButton.tsx
import { useState } from 'react'
import { SaveForm } from './SaveForm'
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms))
export const NewItemButton = () => {
const [saved, setSaved] = useState<string | null>(null)
const handleClick = async () => {
const result = await SaveForm.call({
mutationFn: async (call, { name, shouldFail }) => {
await sleep(900)
// Handle your own errors inside the mutationFn — the lib lets a
// throw propagate untouched. Not calling call.end() leaves the
// dialog open, so the user can fix things and retry.
try {
if (shouldFail) throw new Error('Saving failed — try again.')
call.end(name)
} catch {
// surface this however your UI needs; here we just stay open
}
},
})
if (result) setSaved(result)
}
return (
<div className="flex flex-col items-center gap-3">
<button
type="button"
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)]"
>
New item
</button>
<span className="font-mono text-xs text-[var(--color-fg-subtle)]">
{saved ? (
<span className="text-[var(--color-accent)]">→ saved "{saved}"</span>
) : (
'→ tick "simulate a failed save" to see it stay open'
)}
</span>
</div>
)
}

What changes with useMutationFlow

Without it, you’d track pending state and the close decision by hand. The hook owns pending — true when the trigger fires, cleared when your handler settles — so you disable inputs against submit.pending instead of juggling a boolean. Everything else stays yours: the handler decides whether to close (call call.end(value) or don’t) and handles its own errors; the hook never touches the throw.

Tick “simulate a failed save” and submit to see it: the handler skips call.end(), the hook clears pending, the input keeps its value, and the dialog stays open for a retry.

When to reach for it

  • A submit button that should disable itself while the request is in flight.
  • “Stay open on failure” semantics — the user shouldn’t lose what they typed.
  • The handler is provided at the call site, not declared in the Callable. This lets two different callers run different mutations against the same form.

When not to

If the dialog’s purpose is just to read a value (color picker, item picker), the mutation lives outside the dialog. The caller does const id = await Picker.call(…); await api.delete(id) — keeps the picker pure and reusable.

Related examples