From f669dbee3a1e72d7dd2acca9c31195f1c94960a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 22 Jul 2026 02:21:36 +0200 Subject: [PATCH] Add Mutation docs --- packages/docs/docs/forms.md | 8 +- packages/docs/docs/mutation.md | 270 +++++++++++++++++++++++++++++++++ packages/docs/docs/query.md | 6 +- packages/docs/sidebars.ts | 8 +- 4 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 packages/docs/docs/mutation.md diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md index 0b31625..9e64aff 100644 --- a/packages/docs/docs/forms.md +++ b/packages/docs/docs/forms.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 title: Forms --- @@ -91,7 +91,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () { disabled={!canCommit || isCommitting} onClick={() => void runPromise(form.submit)} > - {isCommitting ? "Creating…" : "Create profile"} + {isCommitting ? "Creating..." : "Create profile"} ) }) @@ -146,7 +146,7 @@ const EditProfileView = Component.make("EditProfile")(function* () { return ( {isCommitting - ? "Saving…" + ? "Saving..." : `${savedProfile.displayName} is ${savedProfile.age}`} ) @@ -238,7 +238,7 @@ const TextInputView = Component.make("TextInput")( input.setValue(event.currentTarget.value) } /> - {isValidating && Validating…} + {isValidating && Validating...} {issues.map((issue, index) => ( {issue.message} ))} diff --git a/packages/docs/docs/mutation.md b/packages/docs/docs/mutation.md new file mode 100644 index 0000000..6d4589a --- /dev/null +++ b/packages/docs/docs/mutation.md @@ -0,0 +1,270 @@ +--- +sidebar_position: 4 +title: Mutation +--- + +# Mutation + +The Mutation module is Effect View's counterpart to TanStack Query mutations. +It models user-triggered asynchronous work such as saving a profile, deleting a +record, uploading a file, or sending a command to an API. + +The familiar concepts are present, but the operation itself is an Effect: + +| TanStack Mutation concept | Effect View equivalent | +| --- | --- | +| Mutation variables | The input key `K` | +| `mutationFn` | `f: (key: K) => Effect` | +| Mutation result | `mutation.state`, a `View>` | +| `isPending` | `result.waiting` | +| `mutateAsync` | `mutation.mutate(key)` | +| Start without awaiting | `mutation.mutateView(key)` | + +Unlike a Query, a Mutation has no cache, reactive key, or automatic execution. +It runs only when `mutate` or `mutateView` is called. This makes it suitable for +commands, while Query remains the model for cached server state. + +## Create a mutation + +Create a mutation in a component scope with `Mutation.make`. Its function can +be any Effect, including one that requires services from the application +runtime: + +```tsx +import { Effect } from "effect" +import { AsyncResult } from "effect/unstable/reactivity" +import { Component, Mutation, View } from "effect-view" +import { sendInvite } from "./api" + +interface InviteInput { + readonly email: string + readonly role: "member" | "admin" +} + +const InviteButtonView = Component.make("InviteButton")( + function* (props: { readonly email: string }) { + const mutation = yield* Component.useOnMount(() => + Mutation.make({ + f: (input: InviteInput) => sendInvite(input), + }), + ) + + const [result] = yield* View.useAll([mutation.state]) + const runSync = yield* Component.useRunSync() + + return ( +
+ + + {AsyncResult.match(result, { + onInitial: () => null, + onFailure: ({ cause }) => ( +

Could not send invite: {cause.toString()}

+ ), + onSuccess: ({ value }) => ( +

Invite sent to {value.email}

+ ), + })} +
+ ) + }, +) +``` + +`Mutation.make` captures the current Effect context. If `sendInvite` requires +an HTTP client, authentication service, tracer, or another service, provide it +to the runtime or component layer before creating the mutation. The click +handler does not need to reconstruct those dependencies. + +The mutation also belongs to the creation scope. Any mutation fibers still +running when that scope closes are interrupted automatically. + +## Understand AsyncResult + +`mutation.state` starts as `Initial` with `waiting: false`. Starting a mutation +changes it to a waiting state, then publishes either `Success` or `Failure`: + +```tsx +const [result] = yield* View.useAll([mutation.state]) + +return AsyncResult.match(result, { + onInitial: ({ waiting }) => + waiting ?

Starting...

:

Ready.

, + onFailure: ({ cause, previousSuccess, waiting }) => ( +
+

{cause.toString()}

+ {previousSuccess._tag === "Some" && ( +

Last saved value: {previousSuccess.value.value.name}

+ )} + {waiting &&

Trying again...

} +
+ ), + onSuccess: ({ value, waiting }) => ( +
+

Saved {value.name}

+ {waiting &&

Saving a newer value...

} +
+ ), +}) +``` + +The `waiting` flag is independent from the result tag. After one successful +call, starting another keeps the successful value and sets `waiting: true`. +If that next call fails, the failure can retain the earlier success in +`previousSuccess`. Components can therefore keep useful feedback visible +during retries or repeated submissions. + +A failure contains an Effect `Cause`, not only `E`. Typed failures, defects, +and interruption information remain available for logging or presentation. + +## Choose mutate or mutateView + +Both methods start the same mutation effect. They differ in what the caller +waits for: + +| Method | Return value | Best suited to | +| --- | --- | --- | +| `mutate(key)` | The final `Success` or `Failure` | Effect workflows that need the outcome. | +| `mutateView(key)` | A live `View>` | UI callbacks that should return after starting the work. | + +Use `mutate` with an asynchronous component runner when later logic depends on +the final result: + +```tsx +const runPromise = yield* Component.useRunPromise() + +const save = () => + void runPromise( + Effect.gen(function* () { + const result = yield* mutation.mutate(input) + + if (AsyncResult.isSuccess(result)) { + yield* Effect.log(`Saved record ${result.value.id}`) + } + }), + ) +``` + +The mutation Effect does not fail with `E`. It captures the operation's `Exit` +and returns a final `AsyncResult.Success` or `AsyncResult.Failure`. Inspect or +match that value when control flow depends on the outcome. + +Use `mutateView` when the UI only needs to start the operation and react to its +state: + +```tsx +const runSync = yield* Component.useRunSync() + +const startSave = () => { + const state = runSync(mutation.mutateView(input)) + // `state` is a View for this specific call. +} +``` + +`mutateView` starts the scoped work and returns immediately with a per-call +View. The shared `mutation.state` is also updated as that call progresses. + +## Track the latest call + +In addition to `state`, a mutation exposes reactive metadata: + +| Member | Meaning | +| --- | --- | +| `state` | The latest mutation state published to the shared View. | +| `latestKey` | The most recently supplied input as an `Option`. | +| `latestFinalResult` | The latest completed success or failure as an `Option`. | +| `fiber` | The most recently started mutation fiber as an `Option`. | + +Subscribe to any of them with `View.useAll`: + +```tsx +const [result, latestInput, latestFinal] = yield* View.useAll([ + mutation.state, + mutation.latestKey, + mutation.latestFinalResult, +]) +``` + +The input is called a `key` in the generic API, but it is equivalent to +mutation variables in TanStack Query. It can be a primitive, tuple, struct, or +any other value accepted by the mutation function. + +## Concurrent mutations + +Starting a mutation does not automatically interrupt an earlier mutation. +Calls may overlap, and each call has its own state View. This is useful for +independent operations such as uploading several files. + +When calls overlap, `mutation.state` reflects updates published by all calls; +the last update to arrive wins. Use the View returned by `mutateView` when each +concurrent operation needs its own progress indicator: + +```tsx +const upload = (file: File) => + Effect.gen(function* () { + const uploadState = yield* mutation.mutateView(file) + // Store or pass `uploadState` to the row rendering this file. + return uploadState + }) +``` + +For a single submit button, disabling it while `mutation.state.waiting` is +usually enough to prevent accidental overlap. For “latest request wins” +behavior, model that policy explicitly or use Query when the operation is +actually a reactive read. + +## Update queries after a mutation + +Mutations do not invalidate Query caches automatically. Compose invalidation +with the mutation result so the relationship remains explicit and typed: + +```tsx +const saveAndRefresh = (input: UpdatePostInput) => + Effect.gen(function* () { + const result = yield* updatePost.mutate(input) + + if (AsyncResult.isSuccess(result)) { + yield* posts.invalidateCacheEntry(["post", result.value.id] as const) + yield* posts.refreshView + } + + return result + }) +``` + +This is the Effect equivalent of an `onSuccess` callback that invalidates a +TanStack Query. Because it is ordinary Effect composition, the workflow can +also include tracing, transactions, retries, notifications, or parallel cache +updates without introducing a separate callback API. + +Remember that Query invalidation removes cached data but does not refetch by +itself. Follow it with `refreshView` when the current screen should update +immediately. + +## The Effect touch + +Mutation follows the ergonomics of TanStack mutations while retaining Effect's +execution model: + +- The mutation function has the full `Effect` type. +- Required services are captured from the creation context. +- Failures are represented as `Cause`, including defects and interruption. +- Fibers are scoped, so component unmounting cleans up in-flight operations. +- `mutate` composes directly inside larger Effect workflows. +- `mutateView` exposes call-specific progress as a View for React or Effect + consumers. +- Retry schedules, timeouts, tracing, logging, metrics, schema validation, and + concurrency controls can be applied with normal Effect operators. + +Mutation is therefore a small bridge: TanStack-style mutation state on one +side, and an ordinary, typed Effect program on the other. diff --git a/packages/docs/docs/query.md b/packages/docs/docs/query.md index 86bc53f..a3b1403 100644 --- a/packages/docs/docs/query.md +++ b/packages/docs/docs/query.md @@ -142,19 +142,19 @@ const [state] = yield* View.useAll([query.state]) return AsyncResult.match(state.result, { onInitial: ({ waiting }) => - waiting ?

Loading…

:

Not loaded.

, + waiting ?

Loading...

:

Not loaded.

, onFailure: ({ cause, previousSuccess, waiting }) => (

Request failed: {cause.toString()}

{previousSuccess._tag === "Some" && (

Last post: {previousSuccess.value.value.title}

)} - {waiting &&

Trying again…

} + {waiting &&

Trying again...

}
), onSuccess: ({ value, waiting }) => (
- {waiting && Refreshing…} + {waiting && Refreshing...}

{value.title}

{value.body}

diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 3314cdb..7f3da56 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -3,7 +3,13 @@ import type { SidebarsConfig } from "@docusaurus/plugin-content-docs" // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) const sidebars: SidebarsConfig = { - docsSidebar: ["getting-started", "state-management", "query", "forms"], + docsSidebar: [ + "getting-started", + "state-management", + "query", + "mutation", + "forms", + ], } export default sidebars