Skip to content

feat: mutation-flow composition hook (lighter alternative to PR #81)#82

Merged
desko27 merged 9 commits into
mainfrom
feat/mutation-flow
May 25, 2026
Merged

feat: mutation-flow composition hook (lighter alternative to PR #81)#82
desko27 merged 9 commits into
mainfrom
feat/mutation-flow

Conversation

@desko27
Copy link
Copy Markdown
Owner

@desko27 desko27 commented May 23, 2026

Summary

Closes #22 with a lighter alternative to PR #81. Ships useMutationFlow as an opt-in composition hook from a new subpath entry react-call/mutation-flow, leaving createCallable and CallContext untouched.

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 view (just { end }) — no RootProps leaks into the handler. Throws are swallowed by the trigger so the call stays open for retry.

When the mutationFn parameter is typed as possibly-undefined, submit(payload) returns a chain object whose .orEnd(value) delivers the Fallback response at the callsite. Each button can chain its own value:

// Picker — per-callsite fallback aligned with payload
<button onClick={() => submit({ choice: 'A' }).orEnd('A')}>A</button>
<button onClick={() => submit({ choice: 'B' }).orEnd('B')}>B</button>

If the consumer prefers to leave the call open when no mutationFn is provided (e.g. let a "No" button handle the close), they omit .orEnd — the Manual-close path. See ADR-0014 for the full design rationale, the rejected alternatives (primitive on CallContext PR #81 shape; options-object signature with TS-enforced fallback), and the trade-offs.

Domain vocabulary in CONTEXT.md: MutationFlow, MutationFn, MutationCall, Trigger, Fallback response, Manual-close path.

How this compares to #81

PR #81 This PR
Changes in createCallable core +159 LOC 0
Changes in CallContext adds pending, mutate none
Generic reorder BREAKING (<Props, Response, Payload, RootProps>) none
Main bundle budget 1 KB → 1.25 KB brotli unchanged (1 KB)
Where mutationFn lives CallOptions (2nd arg of call() / upsert()) regular prop
Mid-call mutationFn swap via Callable.update not possible (set-once) works (it's a prop)
Payload typing scope 3rd generic of createCallable (per-Callable) generic of the hook (per-trigger)
Fallback declaration site CallOptions on call() callsite chain .orEnd(value) (per-button)
Callable.mutate from caller scope deferred (additive door open) structurally absent
HMR preservation of pending across saves yes (store-level) no (local useState)

Bundle impact

  • dist/main.js — 804 B brotli (unchanged from main)
  • dist/main.cjs — 885 B brotli (unchanged from main)
  • dist/mutation-flow.js — 268 B brotli (new, opt-in)
  • dist/mutation-flow.cjs — 344 B brotli (new, opt-in)

Consumers who never import the subpath pay zero.

Commits

  1. 2a3b897 docs(adr): ADR-0014 + CONTEXT.md
  2. 9b56279 feat(react-call): mutation-flow subpath hook (initial implementation)
  3. c706ab9 refactor(mutation-flow): options-object signature iteration
  4. 8169fc4 docs(context): chain-based fallback delivery + Manual-close path
  5. ff5bcd0 docs(adr-0014): amend for chain-based signature
  6. db6846b refactor(mutation-flow): positional signature + chain-based fallback
  7. 9ee7b96 chore(claude): add empty attribution config
  8. 7e41c5a chore(skills): add crafting-effective-readmes from softaworks/agent-toolkit
  9. 3f24a75 docs(readme): rewrite useMutationFlow section via crafting-effective-readmes

Commit 3 (options-object iteration) is preserved as part of the design trail recorded in ADR-0014's "Considered options" section.

Out of scope (same deferrals as PR #81)

  • AbortSignal on MutationCall — additive type-wise, no breaking change to add later
  • submit.error on the trigger — additive without breaking change
  • Callable.mutate(...) from caller scope — structurally absent in this design (see ADR-0014 "Consequences"); PR feat: async mutation primitive (call.pending + call.mutate) #81 left this door open at the cost of the primitive-on-CallContext shape

Test plan

  • pnpm vitest run — 87/87 pass (11 in mutation-flow.test.tsx) covering: pending lifecycle, throw-swallow, fallback via .orEnd, per-callsite fallback (Picker A/B), Manual-close path, re-entry guard, payload forwarding, mid-call mutationFn swap, external end during pending
  • pnpm check:types — clean
  • pnpm lint — clean
  • pnpm --filter react-call run build — three entries emitted (main, vite, mutation-flow)
  • pnpm --filter react-call size — all four budgets pass (main unchanged at 804/885 B; mutation-flow 268 B ESM / 344 B CJS, below 300/400 B limits)

desko27 added 2 commits May 24, 2026 00:34
ADR-0014 records the decision to solve issue #22 via a composition hook
in a `react-call/mutation-flow` subpath instead of extending `CallContext`
with a `call.pending` / `call.mutate` primitive. CONTEXT.md captures the
domain glossary the grilling session produced.
`useMutationFlow(call, mutationFn, fallback?)` lives in the new
`react-call/mutation-flow` subpath entry. The hook orchestrates the
async-submission lifecycle (pending toggle, swallow throws, fallback
when no mutationFn) while leaving createCallable and CallContext
untouched. Two TypeScript overloads encode the invariant that
`fallback` is required iff `mutationFn` may be undefined.

Main entry budget unchanged (1 KB brotli). New subpath sits at 271 B
ESM / 336 B CJS brotli. See ADR-0014.
@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 24, 2026 1:43pm

@desko27 desko27 mentioned this pull request May 23, 2026
Replace positional `useMutationFlow(call, mutationFn, fallback?)` with
`useMutationFlow(call, { mutationFn, fallback? })`. Same two-overload
shape encodes the "fallback required iff mutationFn may be undefined"
invariant on the options object instead of on positional arity.

Docs (README, ADR-0014, CONTEXT.md, changeset) and tests updated to the
new shape. Bundle still within budgets (275 B / 353 B brotli).
Glossary update reflecting the API redesign captured in PR #82 grilling:

- Trigger now describes the two callable shapes (void return when
  MutationFn is required; chain object with `.orEnd` when MutationFn
  may be undefined).
- Fallback response is now delivered at the Trigger callsite via
  `.orEnd(value)` instead of via the hook's options object.
- New term: Manual-close path — the legitimate branch where neither
  MutationFn nor Fallback response closes the Call (e.g. the
  consumer wants a "No" button to handle the close).
- Relationships and example dialogue updated to reflect the new
  three-state model.

ADR-0014 amendment in a follow-up commit.
desko27 pushed a commit that referenced this pull request May 24, 2026
Glossary update reflecting the API redesign captured in PR #82 grilling:

- Trigger now describes the two callable shapes (void return when
  MutationFn is required; chain object with `.orEnd` when MutationFn
  may be undefined).
- Fallback response is now delivered at the Trigger callsite via
  `.orEnd(value)` instead of via the hook's options object.
- New term: Manual-close path — the legitimate branch where neither
  MutationFn nor Fallback response closes the Call (e.g. the
  consumer wants a "No" button to handle the close).
- Relationships and example dialogue updated to reflect the new
  three-state model.

ADR-0014 amendment in a follow-up commit.
desko27 added 2 commits May 24, 2026 10:55
The architectural decision (composition hook in a subpath, not
extending CallContext) is unchanged. What this amendment captures:

- Chosen-option bullet updated to the positional signature with
  `submit(payload).orEnd(value)` chain for the Fallback response.
- Options-object signature (the previous iteration in this PR) moved
  to Considered options as an explicit rejected alternative, so a
  future reader doesn't re-propose it without knowing the trade-off.
- Replaced the obsolete "TypeScript overloads on the options object"
  consequence with the new "positional + chain" mechanism, exporting
  both `Trigger<Payload>` and `ChainTrigger<Payload, Response>`.
- New consequence: the type system no longer enforces the Fallback
  response (regression accepted from grilling Q2).
- New consequence: Fallback response is per-Trigger-callsite, not
  per-Callable (Picker-style flexibility from grilling Q3).
- New consequence: the Manual-close path is a supported branch (from
  grilling Q6) — `submit()` without a chain is a legitimate pattern.
useMutationFlow goes back to two positional arguments:

  useMutationFlow(call, mutationFn)

The Fallback response is now delivered at the Trigger callsite via a
method chain instead of via the hook's options object:

  // Required handler
  const submit = useMutationFlow(call, mutationFn)
  <button onClick={() => submit()}>Yes</button>

  // Optional handler — chain .orEnd at the callsite
  const submit = useMutationFlow(call, mutationFn)
  <button onClick={() => submit().orEnd(true)}>Yes</button>

  // Picker — each button chains its own value
  <button onClick={() => submit({ choice: 'A' }).orEnd('A')}>A</button>
  <button onClick={() => submit({ choice: 'B' }).orEnd('B')}>B</button>

Two TypeScript overloads on the mutationFn argument keep the chain
out of reach when the handler is non-nullable: required → submit
returns void; possibly-undefined → submit returns
`{ orEnd(value): void }`. Two exported types describe the shapes:
`Trigger<Payload>` (void return) and `ChainTrigger<Payload, Response>`
(chain return).

When the handler may be undefined and the callsite omits `.orEnd`,
the call stays open until something else closes it (the Manual-close
path documented in ADR-0014).

New tests cover per-callsite fallback (Picker) and the Manual-close
path. Bundle still well within budget: 268 B brotli ESM (limit 300),
344 B brotli CJS (limit 400).
Disables auto-appended Claude attribution lines on commits and PRs
created from this repo.
desko27 added 2 commits May 24, 2026 13:02
…readmes

Shorter, code-first, less prose. Four subsections: main async-handler
example, optional handlers via .orEnd, per-button payload+fallback,
and the Manual-close path.
@desko27 desko27 force-pushed the feat/mutation-flow branch from 6c0e7af to 3f24a75 Compare May 24, 2026 13:42
@desko27 desko27 merged commit 39d1b09 into main May 25, 2026
8 checks passed
@desko27 desko27 deleted the feat/mutation-flow branch May 25, 2026 07:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

async submission scenario

1 participant