Skip to content

Commit c706ab9

Browse files
committed
refactor(mutation-flow): options-object signature for useMutationFlow
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).
1 parent 9b56279 commit c706ab9

6 files changed

Lines changed: 32 additions & 27 deletions

File tree

.changeset/feat-mutation-flow.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"react-call": minor
33
---
44

5-
- **New `react-call/mutation-flow` subpath entry**`useMutationFlow(call, mutationFn, fallback?)` 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.
5+
- **New `react-call/mutation-flow` subpath entry**`useMutationFlow(call, { mutationFn, fallback? })` 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.
66

77
```tsx
88
import { createCallable } from 'react-call'
@@ -12,7 +12,7 @@
1212

1313
export const Confirm = createCallable<Props, boolean>(
1414
({ call, mutationFn }) => {
15-
const submit = useMutationFlow(call, mutationFn)
15+
const submit = useMutationFlow(call, { mutationFn })
1616
return (
1717
<button disabled={submit.pending} onClick={() => submit()}>
1818
Yes
@@ -33,6 +33,6 @@
3333
})
3434
```
3535

36-
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()`. A 2-arg overload applies when the `mutationFn` parameter is required; a 3-arg overload requires a `fallback` response when `mutationFn` may be undefined. See [ADR-0014](docs/adr/0014-mutation-flow-as-composition-hook.md) for the design rationale and the trade-off versus making this a primitive on `CallContext`.
36+
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()`. The options object has two overloads: `{ mutationFn }` when the parameter is required, and `{ mutationFn, fallback }` (with `fallback` required by the type system) when `mutationFn` may be undefined. See [ADR-0014](docs/adr/0014-mutation-flow-as-composition-hook.md) for the design rationale and the trade-off versus making this a primitive on `CallContext`.
3737

3838
- **Exports**: `MutationFn`, `MutationCall`, `Trigger`, `useMutationFlow` from `react-call/mutation-flow`.

CONTEXT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ _Avoid_: Submit, runner, dispatcher.
8181

8282
**Fallback response**:
8383
The Response value used when `submit()` fires but no MutationFn was
84-
provided. Required as the 3rd argument to `useMutationFlow` exactly when
84+
provided. Required in `useMutationFlow`'s options object exactly when
8585
the MutationFn parameter is typed as possibly-undefined.
8686
_Avoid_: Default, no-op.
8787

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ type Props = {
278278

279279
export const Confirm = createCallable<Props, boolean>(
280280
({ call, message, mutationFn }) => {
281-
const submit = useMutationFlow(call, mutationFn)
281+
const submit = useMutationFlow(call, { mutationFn })
282282
return (
283283
<div role="dialog">
284284
<p>{message}</p>
@@ -314,9 +314,9 @@ can retry. The `mutationFn` decides when (if ever) to call `call.end()`.
314314

315315
If your component should still close cleanly when no `mutationFn` was
316316
provided (e.g. a plain "Are you sure?" without any side effect), type the
317-
prop as optional and pass a third `fallback` argument to the hook. The
318-
type system enforces this — the fallback is required exactly when the
319-
`mutationFn` parameter may be undefined.
317+
prop as optional and pass a `fallback` alongside it in the options
318+
object. The type system enforces this — `fallback` is required exactly
319+
when the `mutationFn` parameter may be undefined.
320320

321321
```tsx
322322
type Props = {
@@ -326,8 +326,8 @@ type Props = {
326326

327327
export const Confirm = createCallable<Props, boolean>(
328328
({ call, message, mutationFn }) => {
329-
// ↓ closes with `true` if no mutationFn
330-
const submit = useMutationFlow(call, mutationFn, true)
329+
// ↓ closes with `true` if no mutationFn
330+
const submit = useMutationFlow(call, { mutationFn, fallback: true })
331331
return (
332332
<button disabled={submit.pending} onClick={() => submit()}>
333333
Yes
@@ -348,7 +348,7 @@ type Props = {
348348
}
349349

350350
export const Picker = createCallable<Props, string>(({ call, mutationFn }) => {
351-
const submit = useMutationFlow(call, mutationFn)
351+
const submit = useMutationFlow(call, { mutationFn })
352352
return (
353353
<>
354354
<button onClick={() => submit({ choice: 'A' })}>A</button>

docs/adr/0014-mutation-flow-as-composition-hook.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Issue #22 asks for a first-class answer to *click → run async → keep open on
55
## Considered options
66

77
- **Primitive on `CallContext` (PR #81 shape).** `call.pending` and `call.mutate(payload)` exposed by the lib itself, with `mutationFn` living in a new `CallOptions` second-arg of `call()` / `upsert()` and `MutationPayload` inserted as the 3rd generic of `createCallable` (RootProps moves to 4th). Rejected: requires ~+159 LOC in `createCallable`, mutates a public type (`CallContext`), bumps the brotli budget 1 KB → 1.25 KB, and forces a BREAKING reorder of generics on consumers who already used `<Props, Response, RootProps>`. The functional win over the hook is marginal: HMR-preserved `pending` across saves and the option to wire `Callable.mutate(...)` from outside later — both rare. Not worth the lib-core surface area.
8-
- **Composition hook in a subpath (chosen).** `useMutationFlow(call, mutationFn, fallback?)` lives in `react-call/mutation-flow`. Pending is local `useState` inside the user component. The handler receives a narrow `MutationCall<Response>` view of the call (just `{ end }`) and decides when to close. Two TypeScript overloads encode the invariant "fallback is required iff `mutationFn` is possibly-undefined", so the wart of a dead-weight third arg is closed at compile time. Lib core: zero changes.
8+
- **Composition hook in a subpath (chosen).** `useMutationFlow(call, { mutationFn, fallback? })` lives in `react-call/mutation-flow`. Pending is local `useState` inside the user component. The handler receives a narrow `MutationCall<Response>` view of the call (just `{ end }`) and decides when to close. Two TypeScript overloads on the options object encode the invariant "fallback is required iff `mutationFn` is possibly-undefined", so the wart of a dead-weight optional field is closed at compile time. Lib core: zero changes.
99
- **Pure docs / no helper.** Just document the orion-style pattern (`useState` + `try/finally` in the user component, `asyncAction` prop with explicit `close`). Rejected: leaves the boilerplate of `useState`/`finally`/no-mutationFn-fallback at every dialog. The user-mentioned `usePending` and "builder for mutationFn" framing made the "no helper" position too thin.
1010
- **Hook in the main entry instead of a subpath.** One import path, more discoverable, tree-shakeable in modern bundlers. Rejected: the hook is ~100–200 bytes brotli; budgeting it into the main entry would push it past the 1 KB limit and force a budget bump (1 KB → ~1.1 KB). Cheap to keep separate, costly to revisit later if subpath gets adopted and we want to fold it back in.
1111

@@ -18,6 +18,6 @@ Issue #22 asks for a first-class answer to *click → run async → keep open on
1818
- **`Callable.mutate(...)` from caller scope is structurally absent**, not deferred. The trigger is local to the user component's render. Adding caller-scope triggering later would require a parallel lib-level channel — it is *not* an additive extension of this hook. PR #81 left this door open at the cost of the primitive-on-CallContext shape; we trade that door for the smaller lib core.
1919
- **HMR does not preserve `pending` across saves.** Pending lives in component `useState`; a Fast Refresh during an in-flight mutation resets the visible pending flag (the mutation itself continues in background). Acceptable: editing a dialog mid-mutation is exotic.
2020
- **`MutationCall<Response>` is the public name** of the handler's `call` argument. Chosen over `MutationContext` (which would falsely suggest a React `Context`) and over `Closer` (too narrow once we ever add `signal` / `ended` / etc.).
21-
- **The hook signature uses TypeScript overloads.** `mutationFn` required → 2-arg signature with no fallback; `mutationFn` possibly-undefined → 3-arg signature with fallback required. The dead-weight third arg from the original sketch is gone.
21+
- **The hook signature uses TypeScript overloads on the options object.** `mutationFn` required → `{ mutationFn }` (no fallback); `mutationFn` possibly-undefined → `{ mutationFn, fallback }` with fallback required. The dead-weight optional fallback field is gone in the required-handler case.
2222
- **`AbortSignal` on `MutationCall` and `submit.error` on the trigger are deferred**, same as PR #81. Additive without breaking change: extending `MutationCall<Response>` and adding an `error` field to the trigger is purely additive type-wise.
2323
- **The README needs a new section** documenting `react-call/mutation-flow`, distinct from the createCallable basics. The hook isn't part of the "Call your React components" first-impression API; it's an opt-in helper for a specific pattern, and the docs should frame it that way.

packages/react-call/src/__tests__/mutation-flow.test.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import type * as ReactCall from '../types.public'
77
import { withAct } from './shared/act'
88

99
// Component fixtures cover the two consumer patterns: a Required-handler
10-
// dialog (the 2-arg overload, no fallback) and an Optional-handler dialog
11-
// (the 3-arg overload, fallback required by the type system).
10+
// dialog (no fallback) and an Optional-handler dialog (fallback required
11+
// by the type system because mutationFn may be undefined).
1212

1313
type RequiredProps = { mutationFn: MutationFn<boolean> }
1414

@@ -18,7 +18,7 @@ const RequiredComponent: ReactCall.UserComponent<
1818
{}
1919
> = ({ call, mutationFn }) => {
2020
const a11yId = useId()
21-
const submit = useMutationFlow(call, mutationFn)
21+
const submit = useMutationFlow(call, { mutationFn })
2222
return (
2323
<div role="dialog" aria-labelledby={a11yId}>
2424
<p id={a11yId}>Are you sure?</p>
@@ -47,7 +47,7 @@ const OptionalComponent: ReactCall.UserComponent<
4747
boolean,
4848
{}
4949
> = ({ call, mutationFn }) => {
50-
const submit = useMutationFlow(call, mutationFn, true)
50+
const submit = useMutationFlow(call, { mutationFn, fallback: true })
5151
return (
5252
<button
5353
type="button"
@@ -69,7 +69,7 @@ const PayloadComponent: ReactCall.UserComponent<PayloadProps, string, {}> = ({
6969
call,
7070
mutationFn,
7171
}) => {
72-
const submit = useMutationFlow(call, mutationFn)
72+
const submit = useMutationFlow(call, { mutationFn })
7373
return (
7474
<>
7575
<button type="button" onClick={() => submit({ choice: 'A' })}>
@@ -129,7 +129,7 @@ describe('useMutationFlow — pending lifecycle', () => {
129129
})
130130
})
131131

132-
describe('useMutationFlow — fallback (3-arg overload)', () => {
132+
describe('useMutationFlow — fallback', () => {
133133
test('submit() without a mutationFn closes the call with the fallback response', async () => {
134134
render(<Optional />)
135135
const promise = withAct(() => Optional.call({}))

packages/react-call/src/mutation-flow/index.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,23 +31,28 @@ export type Trigger<Payload> = ((payload: Payload) => void) & {
3131

3232
// Overloads encode the invariant "fallback is required iff mutationFn
3333
// may be undefined". When the consumer types the prop as required, the
34-
// 2-arg form applies and no fallback is needed. When the prop is
35-
// possibly-undefined, TypeScript forces the 3-arg form. No third dead-
36-
// weight optional argument leaks into the required-handler case.
34+
// fallback-less options shape applies. When the prop is possibly-
35+
// undefined, TypeScript forces the shape that requires `fallback`. No
36+
// dead-weight optional field leaks into the required-handler case.
3737
export function useMutationFlow<Response, Payload = void>(
3838
call: MutationCall<Response>,
39-
mutationFn: MutationFn<Response, Payload>,
39+
options: { mutationFn: MutationFn<Response, Payload> },
4040
): Trigger<Payload>
4141
export function useMutationFlow<Response, Payload = void>(
4242
call: MutationCall<Response>,
43-
mutationFn: MutationFn<Response, Payload> | undefined,
44-
fallback: Response,
43+
options: {
44+
mutationFn: MutationFn<Response, Payload> | undefined
45+
fallback: Response
46+
},
4547
): Trigger<Payload>
4648
export function useMutationFlow<Response, Payload = void>(
4749
call: MutationCall<Response>,
48-
mutationFn: MutationFn<Response, Payload> | undefined,
49-
fallback?: Response,
50+
options: {
51+
mutationFn: MutationFn<Response, Payload> | undefined
52+
fallback?: Response
53+
},
5054
): Trigger<Payload> {
55+
const { mutationFn, fallback } = options
5156
const [pending, setPending] = useState(false)
5257
// Synchronous re-entry guard. `pending` state alone can't guard against
5358
// a second call dispatched programmatically inside the same event-loop

0 commit comments

Comments
 (0)