Skip to main content
Version: effect-view v0

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 conceptEffect View equivalent
Mutation variablesThe input key K
mutationFnf: (key: K) => Effect<A, E, R>
Mutation resultmutation.state, a View<AsyncResult<A, E>>
isPendingresult.waiting
mutateAsyncmutation.mutate(key)
Start without awaitingmutation.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:

MethodReturn valueBest suited to
mutate(key)The final Success or FailureEffect workflows that need the outcome.
mutateStream(key)A Stream of this call's successive states, ending once it settlesUI 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:

MemberMeaning
stateThe latest mutation state published to the shared View.
latestKeyThe most recently supplied input as an Option<K>.
latestFinalResultThe latest completed success or failure as an Option.
operationThe 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.

networkModeBehavior
"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.
  • mutate composes directly inside larger Effect workflows.
  • mutateStream exposes call-specific progress as a Stream 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.