Skip to content

feat: async mutation primitive (call.pending + call.mutate)#81

Closed
desko27 wants to merge 3 commits into
mainfrom
feat/async-mutation
Closed

feat: async mutation primitive (call.pending + call.mutate)#81
desko27 wants to merge 3 commits into
mainfrom
feat/async-mutation

Conversation

@desko27
Copy link
Copy Markdown
Owner

@desko27 desko27 commented May 23, 2026

Summary

Closes #22 (open since Dec 2024) with a first-class answer to the canonical async-submission flow: click → run async op → disable while loading → keep open on failure / end on success.

Two additions to CallContext:

  • call.pending: boolean — true while an in-flight mutation hasn't settled.
  • call.mutate(payload) — managed-pending trigger that invokes the caller-provided mutationFn, swallows throws, and lets the mutationFn decide when to call.end().

The mutationFn lives in a new optional CallOptions slot — the second arg of call() / upsert()not in Props. Naming follows TanStack Query (mutate / mutationFn / pending) for free familiarity; the one deviation is payload instead of variables (the mutationFn closes over half its inputs in caller scope, so what flows through call.mutate(...) is the modal's contribution only — not "all variables").

type Props = { message: string }
type Payload = { id: string }

export const Confirm = createCallable<Props, boolean, Payload>(({ call, message }) => (
  <div role="dialog">
    <p>{message}</p>
    <button disabled={call.pending} onClick={() => call.mutate({ id: 'abc' })}>Delete</button>
    <button onClick={() => call.end(false)}>Cancel</button>
  </div>
))

Confirm.call(
  { message: 'Delete?' },
  {
    mutationFn: async (call, { id }) => {
      try {
        await api.delete(id)
        call.end(true)
        toast.success('Deleted')
      } catch (err) {
        toast.error(err)
        // no end → dialog stays open, pending clears, user can retry
      }
    },
  },
)

Full design rationale + considered alternatives in ADR-0014. Grilling session that produced the design is captured in CONTEXT.md.

⚠️ BREAKING — generic reorder

createCallable's signature reorders to <Props, Response, MutationPayload = void, RootProps = {}>. RootProps moves from 3rd to 4th position. Mechanical migration:

// before
createCallable<Props, Response, RootProps>(Component)
// after
createCallable<Props, Response, void, RootProps>(Component)

The (small) set of consumers using the 3rd generic for RootProps insert void in the new 3rd slot. Bundle budget raised from 1 KB → 1.25 KB brotli to fit the new primitive honestly (alternative was crunching dev warnings the consumer's bundler strips for prod anyway).

Commits

  1. 65abf28 docs(adr): design decision + CONTEXT.md
  2. 8846e66 feat(react-call): implementation + types + tests + readme + changeset
  3. 26a6b09 chore(react-call): silence two biome warnings

Out of scope (deliberate, additive later if real demand)

  • Callable.mutate(...) from outside
  • AbortSignal on MutationContext
  • call.error on CallContext

ADR-0014 documents why each was deferred.

Test plan

  • pnpm vitest run — 94/94 pass (17 new in mutate.test.tsx)
  • pnpm check:types — clean
  • pnpm lint — clean (2 prior warnings silenced via biome-ignore with reasons)
  • pnpm --filter react-call run build + pnpm size — within new 1.25 KB brotli budget
  • Manual smoke in apps/vite / apps/nextjs (not done — these apps don't exercise the mutation path; would be a follow-up if you want a apps/* demo of the pattern)
  • Demo scene in sites/demo (also not done — happy to add a YourMutation.tsx scene in a follow-up)

Do NOT merge

Per request, leaving for manual merge.

desko27 added 3 commits May 23, 2026 23:09
Captures the v2 decision to add `call.pending` + `call.mutate(payload)`
to the CallContext, with `mutationFn` in a new CallOptions slot. Also
adds CONTEXT.md as the project's first domain glossary, documenting the
modal/caller/library responsibility split and the concurrency rules.

Closes the design grilling for #22; implementation follows in the next
commit.
Closes #22 (open since Dec 2024) with a first-class answer to the
"accept button → async op → disable while loading → keep open on
failure / end on success" flow. See ADR-0014 for the design rationale
and considered alternatives.

New on CallContext:
  - call.pending: boolean — in-flight signal for disabled buttons /
    spinners
  - call.mutate(payload) — managed-pending trigger that invokes the
    caller-provided mutationFn, swallows throws (caller's own try/catch
    handles UI side-effects), and lets the mutationFn decide when to
    call.end()

The mutationFn lives in an optional CallOptions slot — the new 2nd
arg of call() / upsert() — not in Props. This keeps the modal's Props
strictly UI; the caller closes over domain helpers (mutation hooks,
alerts, translations) in mutationFn.

Naming follows TanStack Query (mutate / mutationFn / pending) for free
familiarity. The single deviation: the modal-supplied data is called
"payload", not "variables", because the mutationFn closes over half its
inputs in caller scope — what flows through call.mutate(...) is the
modal's contribution only, not "all variables" of the mutation.

BREAKING: createCallable's generic signature reorders to
  <Props, Response, MutationPayload = void, RootProps = {}>
RootProps moves from 3rd to 4th position; consumers who passed three
generics to use RootProps need `void` in the new 3rd slot. v2 umbrella
established in ADR-0003 covers the cost. Mechanical migration.

Conditional tuple makes call() ergonomic when Props = void —
Confirm.call({ mutationFn }) is valid; the options slot promotes to
first arg without forcing an `undefined` placeholder. Same flavour as
the existing end() / update() targeted/untargeted overloads.

Concurrency:
  - One mutation in-flight per call; re-entrant mutate is no-op + dev
    warn
  - call.end() during pending wins; the still-running mutationFn's
    eventual call.end(value) is a silent no-op
  - pending is per-call, not per-mutationFn

Helper types exported (under both named exports and the ReactCall.*
namespace): MutationContext<Response>, MutationFunction<Payload,
Response>, CallOptions<Payload, Response>.

Bundle budget raised from 1 KB to 1.25 KB brotli — honest reflection
of the new functionality. The alternative was crunching the dev
warnings (which the consumer's bundler strips for prod anyway).

Out of scope for v2 (deliberate, additive later if real demand): no
Callable.mutate() from outside, no AbortSignal on MutationContext, no
call.error on CallContext. ADR-0014 documents why.

Tests: 17 new in mutate.test.tsx covering happy path, error swallow,
re-entrancy guard, end-during-pending race, upsert symmetry, missing
mutationFn warn, and the void-Props ergonomic form. 94/94 pass; size
within new budget; tsc clean.
Two leftover warnings after the feat commit:

1. mutate.test.tsx: `type NoProps = void` was matching the lib's
   `Props = void` default to exercise the void-Props ergonomic form.
   biome's `noConfusingVoidType` doesn't know that context — same
   `biome-ignore` reason is already used in callable-types.test.ts.

2. createCallable/index.tsx: the `storedMutationFn!(...)` non-null
   assertion was correct (the early-return guard above proves it
   defined), but TypeScript doesn't narrow the `let` across the
   closure boundary. Capture into a `const fn` after the guard so
   the closure sees the narrowed type and no assertion is needed.

No behaviour change.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-call Ready Ready Preview, Comment May 23, 2026 9:12pm

@desko27
Copy link
Copy Markdown
Owner Author

desko27 commented May 24, 2026

Close in favor of #82

desko27 added a commit that referenced this pull request May 25, 2026
feat: mutation-flow composition hook (lighter alternative to PR #81)
@desko27 desko27 deleted the feat/async-mutation branch May 25, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

async submission scenario

1 participant