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<A, E, R> |
| Mutation result | mutation.state, a View<AsyncResult<A, E>> |
isPending | result.waiting |
mutateAsync | mutation.mutate(key) |
| Start without awaiting | mutation.mutateStream(key) |
Unlike a Query, a Mutation has no cache, reactive key, or automatic execution.
It runs only when mutate or mutateStream 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:
Mutation.make is an Effect constructor, not a hook. Create each mutation
instance once and keep it stable instead of calling Mutation.make again on
every render. Use Component.useOnMount for a component-owned mutation or
create it in an Effect service when it should be shared.
import { Effect } from "effect"
import { AsyncResult } from "effect/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 (
<section>
<button
disabled={result.waiting}
onClick={() =>
runSync(mutation.mutateStream({
email: props.email,
role: "member",
}))
}
>
{result.waiting ? "Sending..." : "Send invite"}
</button>
{AsyncResult.match(result, {
onInitial: () => null,
onFailure: ({ cause }) => (
<p>Could not send invite: {cause.toString()}</p>
),
onSuccess: ({ value }) => (
<p>Invite sent to {value.email}</p>
),
})}
</section>
)
},
)
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:
const [result] = yield* View.useAll([mutation.state])
return AsyncResult.match(result, {
onInitial: ({ waiting }) =>
waiting ? <p>Starting...</p> : <p>Ready.</p>,
onFailure: ({ cause, previousSuccess, waiting }) => (
<div>
<p>{cause.toString()}</p>
{previousSuccess._tag === "Some" && (
<p>Last saved value: {previousSuccess.value.value.name}</p>
)}
{waiting && <p>Trying again...</p>}
</div>
),
onSuccess: ({ value, waiting }) => (
<div>
<p>Saved {value.name}</p>
{waiting && <p>Saving a newer value...</p>}
</div>
),
})
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<E>, not only E. Typed failures, defects,
and interruption information remain available for logging or presentation.
Choose mutate or mutateStream
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. |
mutateStream(key) | A Stream of this call's successive states, ending once it settles | UI callbacks that should return after starting the work. |
Use mutate with an asynchronous component runner when later logic depends on
the final result:
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 mutateStream when the UI only needs to start the operation and react to its
state:
const runSync = yield* Component.useRunSync()
const startSave = () => {
const states = runSync(mutation.mutateStream(input))
// `states` streams this specific call's states, and ends once it settles.
}
mutateStream starts the scoped work and returns immediately with a per-call
Stream: it emits the call's current state, then each change, and ends once
the call settles or is interrupted. The shared mutation.state is also updated
as that call progresses. Consume the stream in a component with Stream.use.
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<K>. |
latestFinalResult | The latest completed success or failure as an Option. |
operation | The most recently started call as an Option<Operation>: its key, state, fiber, and interrupt. |
Subscribe to any of them with View.useAll:
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 stream. This is useful for independent operations such as uploading several files.
When calls overlap, mutation.state follows only the most recently started
call: earlier calls still run to completion, but no longer update it. Use the
stream returned by mutateStream when each concurrent operation needs its own
progress indicator:
const upload = (file: File) =>
Effect.gen(function* () {
const uploadStates = yield* mutation.mutateStream(file)
// Pass `uploadStates` to the row rendering this file, e.g. via `Stream.use`.
return uploadStates
})
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.
Network mode
Mutations take a networkMode option, just like queries. When it is not set,
the QueryClient's defaultMutationNetworkMode is used, which is "online"
unless you change it. It is separate from defaultNetworkMode, which only
applies to queries. The connection status comes from the AppConnectivity service,
as described in Query.
networkMode | Behavior |
|---|---|
"online" | A call made offline waits for a connection before running, and state.paused is true meanwhile. |
"offlineFirst" | A call runs right away. If it fails while offline, it is paused until the connection returns, then runs again. |
"always" | Calls run regardless of the connection. |
const mutation = yield* Mutation.make({
f: (input: InviteInput) => sendInvite(input),
networkMode: "always",
})
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:
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.refresh
}
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 refresh or refreshStream 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<A, E, R>type. - Required services are captured from the creation context.
- Failures are represented as
Cause<E>, including defects and interruption. - Fibers are scoped, so component unmounting cleans up in-flight operations.
mutatecomposes directly inside larger Effect workflows.mutateStreamexposes call-specific progress as aStreamfor 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.