Skip to content

react-call@2.0.0

Choose a tag to compare

@github-actions github-actions released this 30 May 00:03
· 84 commits to main since this release
b1df82e

πŸŽ‰ 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 with React.lazy/<Suspense>,
    StrictMode's double-invoke, and HMR re-mounts. If you asserted this at render time,
    move the assertion to the call() site.
  • CallContext cleanup. call.promise, call.resolve, and the internal
    isUpsert flag are gone (never meant to be public). Replace call.resolve(value)
    with call.end(value); the other two have no public equivalent.
  • Flat type exports. The ReactCall namespace 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 at call() time instead of at Root mount time (ADR-0001). This makes the error compatible with React.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 form expect(() => render(<><Root /><Root /></>)).toThrow(...) should now assert the throw at the call() site instead.
    • CallContext (the call prop your UserComponent receives) no longer leaks three internal fields that 1.8.x exposed by accident: promise, resolve, and isUpsert. The public surface is now exactly { key, end, ended, root, index, stackSize }. Migration:
      • Replace call.resolve(value) with call.end(value).
      • call.promise and call.isUpsert have no public-API equivalent β€” they were never meant to be touched.
  • a4cce68: Public types are exported as flat named exports instead of under the ReactCall namespace (ADR-0015). The ReactCall namespace 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.Function CallFunction
    ReactCall.UpsertFunction UpsertFunction
    ReactCall.Context CallContext
    ReactCall.Props PropsWithCall
    ReactCall.UserComponent UserComponent
    ReactCall.Callable Callable

    This aligns the main entry with the react-call/mutation-flow subpath (which already exports flat names like MutationCall, 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. createCallable now keeps active calls (open dialogs, in-flight upserts) alive across saves of the consumer's module. Persistence is gated on a displayName set on the returned Callable:

    export const Confirm = createCallable((props) => {
      /* ... */
    });
    Confirm.displayName = "Confirm";

    Callables without a displayName still HMR β€” only the dialog being edited resets. The new react-call/vite plugin automates the displayName assignment.

    • Callable.Root is 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 } to FunctionComponent<RP> & { Root, call, upsert, end, update }. This is additive β€” existing code using <Confirm.Root /> keeps working unchanged. A consumer who hand-constructed a Callable<...> literal (rare) will get a type error because their literal is not a function; the fix is to use createCallable(), which is the only supported way to produce a Callable.
  • 9b56279: - New react-call/mutation-flow subpath 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 main react-call entry stays unchanged: bundle size and API surface of createCallable / CallContext are 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 mutationFn receives a narrow MutationCall<Response> view ({ end }) β€” no RootProps ever 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) to call.end().

    When the mutationFn parameter 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, useMutationFlow from react-call/mutation-flow.
  • 2e72674: New react-call/host subpath export β€” an imperative mount(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 own createRoot, 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 via useGlobals from @storybook/preview-api or an external store.
    • container?: HTMLElement β€” mount target; defaults to a fresh <div data-react-call-host> appended to document.body.

    Idempotent under HMR: subsequent mount() calls re-render against the cached root (kept on globalThis[Symbol.for('react-call.host')]) rather than creating a second one, so an open Confirm.call() survives edits to preview.tsx.

    Adds react-dom as an optional peer dependency (mirrors the vite optional peer). Consumers who don't import react-call/host see no install change.

    See ADR-0016 for the design discussion.

  • a64c1a3: New react-call/vite subpath 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 form

    export 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) const with createCallable imported by name from 'react-call', optionally renamed). Skips files that already set displayName manually. Requires vite >= 8 (optional peer dependency β€” the runtime library itself has no Vite dependency).

Patch Changes

  • 86df7d7: useMutationFlow no longer swallows throws from the user's mutationFn (ADR-0016). A rejected mutationFn now propagates as a normal unhandled promise rejection β€” visible in the dev console, observable by window.addEventListener('unhandledrejection', ...) and by telemetry tools (Sentry, Datadog) that hook into it.

    The hook's state-machine contract is unchanged: .finally still clears pending and the in-flight guard on both fulfillment and rejection, so the trigger remains retry-ready. The dialog still stays open when a mutationFn doesn't reach call.end() β€” that has always been a property of the Call/Stack lifecycle, not of the swallow.

    If you want to react to a mutationFn failure (surface a toast, route to Sentry, etc.), wrap the body in try/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 useMutationFlow from 2.0.0-next.3 onwards. 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's getServerSnapshot returned a fresh [] each call, so useSyncExternalStore's Object.is comparison 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 in 2.0.0-next.1.