react-call@2.0.0
π react-call v2
createCallable() turns a React component into something you can await. v2 keeps
that API and adds opt-in subpaths β react-call/mutation-flow, react-call/host,
react-call/vite β plus HMR persistence, while staying ~1 KB.
What changed for consumers
The public API is functionally identical to 1.x for correct usage β the breaking
changes target misuse and a stale namespace. Full guide:
Migrating from v1 β
<Root>error timing."Multiple instances of <Root> found!"now throws at
call()time, not at Root mount β compatible withReact.lazy/<Suspense>,
StrictMode's double-invoke, and HMR re-mounts. If you asserted this at render time,
move the assertion to thecall()site.CallContextcleanup.call.promise,call.resolve, and the internal
isUpsertflag are gone (never meant to be public). Replacecall.resolve(value)
withcall.end(value); the other two have no public equivalent.- Flat type exports. The
ReactCallnamespace is removed β types are flat named
exports now (ReactCall.PropsβPropsWithCall,ReactCall.Contextβ
CallContext, β¦). Mechanical find-and-replace; full table in the migration guide.
Major Changes
-
a64c1a3: Breaking changes for 2.0:
"Multiple instances of <Root> found!"now fires atcall()time instead of at Root mount time (ADR-0001). This makes the error compatible withReact.lazy-wrapped Roots inside<Suspense>boundaries, React StrictMode's double-invoke, and HMR re-mounts β all patterns that briefly create transient second listeners that aren't real consumer errors. Migration: any test of the formexpect(() => render(<><Root /><Root /></>)).toThrow(...)should now assert the throw at thecall()site instead.CallContext(thecallprop your UserComponent receives) no longer leaks three internal fields that 1.8.x exposed by accident:promise,resolve, andisUpsert. The public surface is now exactly{ key, end, ended, root, index, stackSize }. Migration:- Replace
call.resolve(value)withcall.end(value). call.promiseandcall.isUpserthave no public-API equivalent β they were never meant to be touched.
- Replace
-
a4cce68: Public types are exported as flat named exports instead of under the
ReactCallnamespace (ADR-0015). TheReactCallnamespace is removed in 2.0 with no deprecated alias β migration is a mechanical find-and-replace:// Before import { createCallable, type ReactCall } from "react-call"; type MyProps = ReactCall.Props<MyInput, MyResponse>; // After import { createCallable, type PropsWithCall } from "react-call"; type MyProps = PropsWithCall<MyInput, MyResponse>;
Mapping:
Before After ReactCall.FunctionCallFunctionReactCall.UpsertFunctionUpsertFunctionReactCall.ContextCallContextReactCall.PropsPropsWithCallReactCall.UserComponentUserComponentReactCall.CallableCallableThis aligns the main entry with the
react-call/mutation-flowsubpath (which already exports flat names likeMutationCall,MutationFn,Trigger) and with the convention of the broader React/TS ecosystem (React Query, React Router, TanStack Table). No runtime change β types are erased at compile time; the JS bundle is unaffected.
Minor Changes
-
a64c1a3: - HMR persistence under Vite Fast Refresh.
createCallablenow keeps active calls (open dialogs, in-flight upserts) alive across saves of the consumer's module. Persistence is gated on adisplayNameset on the returned Callable:export const Confirm = createCallable((props) => { /* ... */ }); Confirm.displayName = "Confirm";
Callables without a
displayNamestill HMR β only the dialog being edited resets. The newreact-call/viteplugin automates thedisplayNameassignment.Callable.Rootis deprecated (no removal date). Both<Confirm />and<Confirm.Root />mount the same component since the Callable IS its own Root component. The deprecation is marked via JSDoc on the type, so editors surface a strikethrough; the property keeps working forever for backwards compatibility.- The
Callable<P, R, RP>type widened from{ Root, call, upsert, end, update }toFunctionComponent<RP> & { Root, call, upsert, end, update }. This is additive β existing code using<Confirm.Root />keeps working unchanged. A consumer who hand-constructed aCallable<...>literal (rare) will get a type error because their literal is not a function; the fix is to usecreateCallable(), which is the only supported way to produce aCallable.
-
9b56279: - New
react-call/mutation-flowsubpath entry βuseMutationFlow(call, mutationFn)is an opt-in hook that wraps the canonical async-submission flow (click β run async β keep open on failure, end on success). The mainreact-callentry stays unchanged: bundle size and API surface ofcreateCallable/CallContextare not affected. Consumers who never import the subpath pay zero.import { createCallable } from "react-call"; import { useMutationFlow, type MutationFn } from "react-call/mutation-flow"; type Props = { mutationFn: MutationFn<boolean> }; export const Confirm = createCallable<Props, boolean>( ({ call, mutationFn }) => { const submit = useMutationFlow(call, mutationFn); return ( <button disabled={submit.pending} onClick={() => submit()}> Yes </button> ); }, ); await Confirm.call({ mutationFn: async (call) => { try { await api.delete(id); call.end(true); } catch (e) { toast.error(e); // dialog stays open, pending clears } }, });
The
mutationFnreceives a narrowMutationCall<Response>view ({ end }) β noRootPropsever leaks into the handler's signature. Throws are swallowed by the trigger so the call stays open for retry; the handler decides when (if ever) tocall.end().When the
mutationFnparameter is typed as possibly-undefined,submit(payload)returns a chain object whose.orEnd(value)closes the call with a fallback at the callsite βsubmit().orEnd(true). Each button can chain its own value (Picker:.orEnd('A')/.orEnd('B')). Omitting the chain is also valid: the call stays open until something else closes it.See ADR-0014 for the design rationale and the trade-off versus making this a primitive on
CallContext.- Exports:
MutationFn,MutationCall,Trigger,ChainTrigger,useMutationFlowfromreact-call/mutation-flow.
- Exports:
-
2e72674: New
react-call/hostsubpath export β an imperativemount(element, options?)helper for environments that render multiple isolated React subtrees in parallel for previewing (Storybook autodocs page, Ladle, Histoire, react-cosmos). Mounts a single shared Root in a body-level<div data-react-call-host>via its owncreateRoot, sidestepping the multi-root call-time throw that decorator-per-story patterns otherwise hit.// .storybook/preview.tsx import { mount } from "react-call/host"; import { Confirm } from "../src/Confirm"; mount(<Confirm />);
Options:
wrapper?: ComponentType<{ children: ReactNode }>β wraps the rendered element in providers (theme, i18n, router). The wrapper runs inside the Confirm's own React tree, so context from story decorators does not propagate; for reactive providers tied to host state (e.g. Storybook globals), subscribe inside the wrapper viauseGlobalsfrom@storybook/preview-apior an external store.container?: HTMLElementβ mount target; defaults to a fresh<div data-react-call-host>appended todocument.body.
Idempotent under HMR: subsequent
mount()calls re-render against the cached root (kept onglobalThis[Symbol.for('react-call.host')]) rather than creating a second one, so an openConfirm.call()survives edits topreview.tsx.Adds
react-domas an optional peer dependency (mirrors theviteoptional peer). Consumers who don't importreact-call/hostsee no install change.See ADR-0016 for the design discussion.
-
a64c1a3: New
react-call/vitesubpath export β a Vite plugin that auto-injects<Callable>.displayName = '<Callable>'for every top-level(export) const X = createCallable(...)it finds in dev mode. With the plugin enabled, the natural formexport const Confirm = createCallable((props) => { /* ... */ });
keeps HMR persistence working without the manual displayName line.
Enable from
vite.config.ts:import react from "@vitejs/plugin-react"; import reactCall from "react-call/vite"; export default { plugins: [react(), reactCall()], };
Dev-only β no production bundle overhead. Strict detection (only top-level
(export) constwithcreateCallableimported by name from'react-call', optionally renamed). Skips files that already setdisplayNamemanually. Requiresvite >= 8(optional peer dependency β the runtime library itself has no Vite dependency).
Patch Changes
-
86df7d7:
useMutationFlowno longer swallows throws from the user'smutationFn(ADR-0016). A rejectedmutationFnnow propagates as a normal unhandled promise rejection β visible in the dev console, observable bywindow.addEventListener('unhandledrejection', ...)and by telemetry tools (Sentry, Datadog) that hook into it.The hook's state-machine contract is unchanged:
.finallystill clearspendingand the in-flight guard on both fulfillment and rejection, so the trigger remains retry-ready. The dialog still stays open when amutationFndoesn't reachcall.end()β that has always been a property of the Call/Stack lifecycle, not of the swallow.If you want to react to a
mutationFnfailure (surface a toast, route to Sentry, etc.), wrap the body intry/catch:mutationFn: async (call) => { try { await api.delete(id); call.end(true); } catch (e) { toast.error(e); Sentry.captureException(e); // no call.end β dialog stays open for retry } };
Only relevant if you adopted
useMutationFlowfrom2.0.0-next.3onwards. The 1.x line never had the hook. -
37d7b7d: Fix React's
"The result of getServerSnapshot should be cached to avoid an infinite loop"warning logged on every render in any SSR consumer (Next.js App Router included).createStackStore'sgetServerSnapshotreturned a fresh[]each call, souseSyncExternalStore'sObject.iscomparison always reported "changed" and React entered the recovery path. Fixed by returning a single stable empty-stack reference per store. No runtime behaviour change for client-only consumers (Vite CSR, CRA, etc.) β they never touched the SSR snapshot path.Surfaced by the new
apps/nextjs/playground introduced in the same release; shipped briefly in2.0.0-next.1.