Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/feat-async-mutation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"react-call": major
---

**Async submission flow as a first-class mutation primitive** (closes #22, see ADR-0014).

Two additions to `CallContext`:

- **`call.pending: boolean`** — true while an in-flight mutation hasn't settled. Component reads to disable inputs / show spinners.
- **`call.mutate(payload)`** — managed-pending trigger. Sets `pending` to true, invokes the caller-provided `mutationFn` with the payload, awaits, sets `pending` back to false. Errors thrown by `mutationFn` are swallowed; the call stays open (the caller's own `try/catch` inside `mutationFn` handles any UI side-effects).

The `mutationFn` lives in a new optional `CallOptions` slot — the second arg of `call()` / `upsert()` — **not** in `Props`. This keeps the modal's Props strictly UI, while the caller closes over domain helpers (mutation hooks, alerts, translations) in `mutationFn`:

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

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

Confirm.call(
{ message: 'Delete?' },
{
mutationFn: async (call, { id }) => {
try {
await api.delete(id)
call.end(true)
alert.success(...)
} catch (e) {
alert.error(...)
// no end / no throw → dialog stays open, pending clears
}
},
},
)
```

**BREAKING — generic reorder.** `createCallable<Props, Response, MutationPayload = void, RootProps = {}>` — `MutationPayload` takes 3rd position and `RootProps` moves to 4th. Consumers who passed three generics to set `RootProps` need to insert `void` in the new 3rd slot. Migration is mechanical:

```ts
// 1.x / pre-2.0
createCallable<Props, Response, RootProps>(Component)

// 2.0
createCallable<Props, Response, void, RootProps>(Component)
```

When `Props = void`, `Confirm.call()` and `Confirm.call({ mutationFn })` are both valid via a conditional tuple — the options slot promotes to first arg.

**Concurrency rules** (enforced by the lib): one mutation in-flight per call; re-entrant `call.mutate()` while `pending` is a no-op (with dev warn); `call.end()` invoked directly during a pending mutation ends immediately and the still-running `mutationFn`'s eventual `call.end(value)` is a silent no-op.

**Dev warnings** (NODE_ENV-gated, stripped from consumer production bundles): `call.mutate()` invoked without a `mutationFn` in CallOptions, and re-entrant mutate while pending.

**Bundle budget** raised from 1 KB to 1.25 KB brotli to accommodate the new primitive. Honest reflection of new functionality — the alternative was crunching helpful dev warnings to fit a budget that pre-dated the feature.

**New helper types exported** (under both named exports and the `ReactCall.*` namespace):

- `MutationContext<Response>` — minimal `{ end }` context the `mutationFn` receives as its first arg.
- `MutationFunction<Payload, Response>` — full signature of the `mutationFn` slot, composable for consumers building typed mutation helpers.
- `CallOptions<Payload, Response>` — shape of the options bag.

**Out of scope for this release** (deliberate, additive later if real demand surfaces): `Callable.mutate(...)` from outside, `AbortSignal` on `MutationContext`, `call.error` on `CallContext`. See ADR-0014.
134 changes: 134 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# react-call domain

Glossary of the terms used across the library, its docs, and its agent skills.

## Async submission flow

The pattern where the user component triggers an asynchronous operation in
response to a click (typically an "Accept" button), needs to render a
loading state while it runs, and must keep the call open if the operation
fails or end the call if it succeeds.

The async work itself is owned by the **caller scope** (it's domain logic
— mutations, API calls — that doesn't belong inside a reusable dialog).
The pending UI state is owned by the **user component** (it's a UI
concern — disabled buttons, spinners — that doesn't belong at the call
site).

react-call's role is to provide call-context primitives that let the
component coordinate the two without re-implementing
`useState`/try-finally per dialog.

## Mutation

The async-submission flow is modeled as a **mutation** (terminology
imported from TanStack Query for free familiarity). Two additions
to `CallContext`:

- **`call.pending: boolean`** — true while an in-flight mutation
hasn't settled. Component reads to disable inputs / show spinners.

- **`call.mutate(payload)`** — managed-pending trigger. Sets
`pending` to true, invokes the caller-provided `mutationFn` with
the payload, awaits, sets `pending` back to false. Errors thrown
by `mutationFn` are swallowed (the call stays open; the caller's
own `try/catch` inside `mutationFn` handles any UI side-effects).
The `mutationFn` itself decides when (if ever) to call
`call.end()` — the lib does not auto-end based on return value.

### Where mutationFn lives

`mutationFn` is **not** a prop. It lives in a separate options
slot on `call()` / `upsert()`:

```ts
Confirm.call(
{ message: 'Delete?' }, // Props
{ mutationFn: async (call, payload) => { ... } } // Options slot
)
```

The second arg is an extensible **options bag** (`CallOptions`),
not a `mutationFn`-only param. Future call-time options (e.g., the
`unmountOnEnd: false` lifecycle override sketched in issue #22) can
slot in alongside without breaking the signature. `upsert()`
accepts the same options bag for symmetry.

This keeps the component's Props strictly UI — the modal never
sees the async logic, and the caller never has to add a
"mutationFn" key to a props object that's otherwise pure UI.

### Exported helper types

- **`MutationContext<Response>`** — the first arg the `mutationFn`
receives. Intentionally minimal: only `end(response)`. `pending`
is meaningless inside `mutationFn` (always true while running);
`ended` is theoretically useful for "bail post-cancel" logic but
marginal — additive later if a real case emerges. Object shape
(not bare `end`) preserves space for future additions
(`signal`, `ended`, …) without breaking the signature.

- **`MutationFunction<Payload, Response>`** — full signature of
the `mutationFn` slot. Composable: consumers can declare
reusable mutation fns and pass them into `CallOptions`.

- **`CallOptions<Payload, Response>`** — shape of the options
bag (`{ mutationFn?: MutationFunction<...> }`). Re-exported so
consumers can build typed call-site helpers.

### Generic positioning (BREAKING in v2)

`createCallable`'s generics reorder to put `MutationPayload`
in 3rd position; `RootProps` moves to 4th:

```ts
createCallable<Props, Response, MutationPayload = void, RootProps = {}>
```

Rationale: `RootProps` is rarely used in practice (most consumers
never customize the Root's accepted props), while `MutationPayload`
is part of the mainline ergonomic flow we're shipping. Putting
the more-used generic earlier minimizes positional verbosity.
Breaking-change cost is borne by the (small) set of consumers
who passed three generics — they re-order one line.

## Responsibility split

- **Modal**: decides *when* to mutate and *what payload* to send
(drawn from UI state — form data, which button, etc.). The
payload is the modal's contribution only — not "all variables"
of the mutation.
- **Caller**: defines `mutationFn` in caller scope, closing over
domain helpers (mutation hooks, translations, alert system, …).
Decides what to do with the payload and what value to close the
call with (`call.end(value)` inside `mutationFn`).
- **Library**: manages `pending` lifecycle, swallows throws, warns
in dev when `call.mutate()` fires but no `mutationFn` was
provided in `CallOptions`.

## What is NOT in v2

- `Callable.mutate(...)` from outside. The mental model is
"modal triggers mutate". External triggers don't fit and have
no obvious semantics for "what payload" without the modal.
Additive in a future minor if a real use case appears.
- `AbortSignal` exposed via `MutationContext`. Useful for
long-running mutations the user cancels, but adds API surface;
additive later if demand surfaces.
- `call.error` exposed on `CallContext` after a thrown mutation.
Consumers who want inline error rendering wire it themselves
via the `try/catch` already inside `mutationFn`. Additive
later if a clean lifecycle for "when does the error clear"
emerges.

## Mutation concurrency rules

- One mutation in-flight per call. Re-entrant `call.mutate(...)`
while `pending` is true is a no-op (with `console.warn` in dev).
- `call.end()` invoked directly during a pending mutation ends
the call immediately; the still-running `mutationFn`'s eventual
`call.end(value)` is a silent no-op.
- `pending` is per-call, not per-mutationFn — a single mutation
in-flight blocks any other on the same call.
- No `AbortSignal` exposed in v2 (additive in a future minor if
consumer demand surfaces).
74 changes: 67 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,62 @@ const showProgress = async () => {
}
```

## Async mutations

For the common "click → run async op → close on success / stay on failure" flow, the `call` prop exposes `call.pending` and `call.mutate(payload)`. The async operation itself lives in a `mutationFn` slot you pass at `call()` time — naming and signature follow TanStack Query so the mental model is the same: `mutationFn` is the work, `mutate(payload)` triggers it, `pending` reflects in-flight state.

```tsx
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>
))

// At the call site, supply the async work in the optional second arg:
Confirm.call(
{ message: 'Delete this item?' },
{
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
}
},
},
)
```

The library handles the `pending` lifecycle (true while the `mutationFn` runs, false when it settles) and silently swallows any throw — your own `try/catch` inside `mutationFn` decides whether to close the dialog (`call.end(value)`) or leave it open for a retry. The mutation context (`call` inside `mutationFn`) exposes only `end` to keep the contract minimal.

**Concurrency rules:**
- Only one mutation can be in-flight per call — re-entrant `call.mutate()` while `pending` is a no-op (with a dev warning).
- `call.end()` invoked directly (e.g. from a Cancel button) during a pending mutation wins; the still-running `mutationFn`'s eventual `call.end(value)` is a silent no-op.

`upsert()` accepts the same options bag for symmetry:

```tsx
Toast.upsert(
{ message: 'Saving…' },
{ mutationFn: async (call, payload) => { /* … */ } },
)
```

If your component doesn't need a payload (e.g. a confirm with no extra data), the `MutationPayload` generic defaults to `void` and `call.mutate()` takes no args.

# Exit animations

To animate the exit of your component when `call.end()` is run, just pass the duration of your animation in milliseconds to createCallable as a second argument:
Expand Down Expand Up @@ -240,7 +296,8 @@ Root props will be available to your component via `call.root` object.
export const Confirm = createCallable<
Props,
Response,
+ RootProps
+ void, // ← MutationPayload (3rd) — pass void if you don't use mutations
+ RootProps // ← RootProps moved to 4th in v2 (ADR-0014)
>(({ call, message }) => (
...
+ Hi {call.root.userName}!
Expand Down Expand Up @@ -313,12 +370,15 @@ import type { ReactCall } from 'react-call'

Type | Description
--- | ---
ReactCall.Function<Props?, Response?> | The call() method
ReactCall.UpsertFunction<Props?, Response?> | The upsert() method
ReactCall.Context<Props?, Response?, RootProps?> | The call prop in UserComponent
ReactCall.Props<Props?, Response?, RootProps?> | Your props + the call prop
ReactCall.UserComponent<Props?, Response?, RootProps?> | What is passed to createCallable
ReactCall.Callable<Props?, Response?, RootProps?> | What createCallable returns
ReactCall.Function<Props?, Response?, MutationPayload?> | The call() method
ReactCall.UpsertFunction<Props?, Response?, MutationPayload?> | The upsert() method
ReactCall.Context<Props?, Response?, MutationPayload?, RootProps?> | The call prop in UserComponent
ReactCall.Props<Props?, Response?, MutationPayload?, RootProps?> | Your props + the call prop
ReactCall.UserComponent<Props?, Response?, MutationPayload?, RootProps?> | What is passed to createCallable
ReactCall.Callable<Props?, Response?, MutationPayload?, RootProps?> | What createCallable returns
ReactCall.CallOptions<MutationPayload?, Response?> | Optional 2nd arg of `call()` / `upsert()` (`{ mutationFn? }`)
ReactCall.MutationContext<Response?> | The `call` arg the `mutationFn` receives (`{ end }`)
ReactCall.MutationFunction<MutationPayload?, Response?> | Full signature of the `mutationFn` slot

# Errors

Expand Down
Loading
Loading