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.
The Callable
declared once, mounted in the React treeimport { 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, onceimport { SaveForm } from './SaveForm'import { NewItemButton } from './NewItemButton'
export default function App() { return ( <> <SaveForm /> <NewItemButton /> </> )}The caller
anywhere in your app, imperativeimport { 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.