## 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
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
}