Files
effect-view/packages/effect-view/ai-docs/Mutation.md
T
ThilawynandJulien Valverdé 0e2b4c1951
Publish / publish (push) Successful in 2m6s
Lint / lint (push) Successful in 1m3s
Improve effect-view docs, mutation keys, and Fast Refresh (#76)
## Summary

- Add comprehensive AI-oriented documentation for effect-view components, state, async rendering, queries, mutations, forms, streams, and runtime setup.
- Fix `Mutation.mutate` so each invocation consistently uses its own key instead of reusing the previous mutation key.
- Improve Vite Fast Refresh instrumentation for:
  - User-defined component wrappers.
  - Data-first and pipeline-based `withContext`/`withRuntime` entrypoints.
  - Nested function handling.
  - Stable hook signatures that preserve state for non-hook-related edits.
- Add regression tests for mutation keys and refresh behavior.
- Bump `effect-view` to `0.1.5` and `@effect-view/vite-plugin` to `0.0.2`.

## Testing

- `bun run --cwd packages/effect-view test -- src/Mutation.test.ts`
  - 4 tests passed
- `bun run --cwd packages/vite-plugin test -- src/plugin.test.ts`
  - 11 tests passed

The full test suite was not run.

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #76
2026-08-26 01:06:03 +02:00

3.9 KiB

Mutation

effect-view's counterpart to TanStack Query mutations: user-triggered asynchronous work (save, delete, upload, send) as an Effect. No cache, no reactive key, no automatic execution — it runs only when called.

TanStack Mutation effect-view
mutation variables the input key K
mutationFn f: (key: K) => Effect<A, E, R>
mutation result mutation.state, a View<{ key: Option<K>; result: AsyncResult<A, E> }>
isPending state.result.waiting
mutateAsync mutation.mutate(key)
start without awaiting mutation.mutateView(key)

Create

import { Mutation } from "effect-view"

const mutation = yield* Component.useOnMount(() =>
  Mutation.make({ f: (input: InviteInput) => sendInvite(input) }),
)

Mutation.make({ f }) is an Effect constructor, not a hook — create each instance once (Component.useOnMount for component-owned, an Effect service for shared) and keep it stable. f keeps its full Effect<A, E, R> type; required services are captured from the creation context, so callbacks don't reconstruct dependencies. Fibers belong to the creation scope and are interrupted if that scope closes while running.

AsyncResult state

mutation.state is a View of { key: Option<K>, result: AsyncResult<A, E> }. result starts Initial (waiting: false); calling mutate/mutateView sets waiting: true, then publishes Success or Failure. Match on state.result, not state itself:

import { AsyncResult } from "effect/unstable/reactivity"

const [state] = yield* View.useAll([mutation.state])

AsyncResult.match(state.result, {
  onInitial: ({ waiting }) => (...),
  onFailure: ({ cause, previousSuccess, waiting }) => (...), // cause: Cause<E>
  onSuccess: ({ value, waiting }) => (...),
})

waiting is independent of the result tag: after one success, starting another call keeps the value visible while waiting: true; if that call fails, the failure can retain previousSuccess. Failures carry a full Cause<E>.

mutate vs mutateView

Method Returns Use for
mutate(key) the final FinalMutationState ({ key: Option.Some<K>, result: Success | Failure }) an Effect workflow that needs the outcome
mutateView(key) a live per-call View<{ key: Option.Some<K>, result: AsyncResult<A, E> }> a UI callback that just starts the work
const runPromise = yield* Component.useRunPromise()
void runPromise(Effect.gen(function* () {
  const final = yield* mutation.mutate(input)
  if (AsyncResult.isSuccess(final.result)) yield* Effect.log(`Saved ${final.result.value.id}`)
}))
const runSync = yield* Component.useRunSync()
const state = runSync(mutation.mutateView(input)) // a View for this specific call

The mutation Effect never fails with E itself — it captures the operation's Exit and always resolves to a final state wrapping an AsyncResult.Success/Failure.

Reactive metadata

Member Meaning
state latest mutation state, shared View
latestKey most recent input, Option<K>
latestFinalState latest completed final state, Option<FinalMutationState<K, A, E>>
fiber most recently started mutation fiber, Option

Concurrency

Starting a mutation does not interrupt an earlier one — calls can overlap, each with its own mutateView state; mutation.state reflects whichever update arrived last. For a single submit button, disabling while result.waiting is usually enough. Use per-call mutateView Views (e.g. per uploaded file) when concurrent operations each need their own progress indicator.

Updating queries after a mutation

Mutations never auto-invalidate Query caches — compose it explicitly:

const final = yield* updatePost.mutate(input)
if (AsyncResult.isSuccess(final.result)) {
  yield* posts.invalidateCacheEntry(["post", final.result.value.id] as const)
  yield* posts.refreshView // invalidation alone does not refetch
}