StunkStunk
Async

Mutation

Reactive mutations for POST, PUT, DELETE and any async side effect.

mutation() wraps an async side effect and gives you reactive loading, error, data, and isSuccess states. Unlike asyncChunk, mutations are imperative — they don't fetch automatically. You call mutate() when you're ready.

import { mutation } from "stunk/query";

const createPost = mutation(
  async (data: NewPost) => {
    const res = await fetch("/api/posts", {
      method: "POST",
      body: JSON.stringify(data),
    });
    return res.json();
  },
  {
    invalidates: [postsChunk],
    onSuccess: (data) => toast.success("Post created!"),
    onError: (err) => toast.error(err.message),
  },
);

State shape

PropertyTypeDescription
loadingbooleantrue while the mutation is in progress
dataT | nullData returned from the last successful mutation, or null
errorE | nullError from the last failed mutation, or null
isSuccessbooleantrue after a successful mutation — distinct from data which can be null on success

Options

OptionTypeDescription
invalidatesAsyncChunk[]Chunks to reload automatically after a successful mutation
onSuccess(data: T, variables: V) => voidCalled after a successful mutation
onError(error: E, variables: V) => voidCalled on failure
onSettled(data: T | null, error: E | null, variables: V) => voidCalled after every attempt regardless of outcome

onSuccess and onError can be set globally via configureQuery({ mutation: {} }). Per-mutation options always override global defaults.


mutate()

Executes the mutation. Always returns a resolved promise — never throws. Safe to fire and forget, or await for local UI control:

// Fire and forget — safe, errors handled by onError
createPost.mutate({ title: "Hello" });

// Await — no try/catch needed
const { data, error } = await createPost.mutate({ title: "Hello" });
if (!error) router.push("/posts");

invalidates

Pass an array of AsyncChunk instances. After a successful mutation, Stunk calls .reload() on each of them concurrently — no query keys, no queryClient, no Promise.all needed:

const createPost = mutation(createPostFn, {
  invalidates: [postsChunk, dashboardChunk],
});

// After mutate() succeeds — postsChunk and dashboardChunk both reload automatically

invalidates only fires on success. A failed mutation leaves all chunks untouched.


onSettled

Called after every attempt — success or failure. Useful for unconditional cleanup like hiding a spinner or closing a modal:

const deletePost = mutation(deletePostFn, {
  onSettled: () => setModalOpen(false), // always close the modal
});

get() and subscribe()

Mutation state is fully reactive — use get() to read it directly, or subscribe() to react to changes outside React:

createPost.get(); // { loading, data, error, isSuccess }

const unsub = createPost.subscribe((state) => {
  if (state.isSuccess) analytics.track("post_created");
});

reset()

Clears data, error, and isSuccess back to initial state:

createPost.reset();
createPost.get(); // { loading: false, data: null, error: null, isSuccess: false }

In React

Use useMutation to consume mutation state reactively in a component:

import { mutation } from "stunk/query";
import { useMutation } from "stunk/react";

const createPost = mutation(
  async (data: NewPost) => fetchAPI("/posts", { method: "POST", body: data }),
  { invalidates: [postsChunk] },
);

function CreatePostForm() {
  const { mutate, loading, error, isSuccess } = useMutation(createPost);

  const handleSubmit = async (formData: NewPost) => {
    const { error } = await mutate(formData);
    if (!error) router.push("/posts");
  };

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        handleSubmit(getFormData(e));
      }}
    >
      {error && <p>Error: {error.message}</p>}
      {isSuccess && <p>Post created!</p>}
      <button type="submit" disabled={loading}>
        {loading ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

Stunk vs React Query

React Query requires two separate functions — mutate (fire and forget, swallows errors) and mutateAsync (returns promise, throws on error):

// React Query — two functions, must choose
const { mutate, mutateAsync } = useMutation({ mutationFn: createPost });

mutate(data); // fire and forget
await mutateAsync(data); // throws on error — requires try/catch

// Invalidation — requires queryClient and query keys
onSuccess: async () => {
  await queryClient.invalidateQueries({ queryKey: ["posts"] });
};

Stunk — one function, always safe, direct chunk references:

// Stunk — one function, both use cases
const { mutate } = useMutation(createPost);

mutate(data); // fire and forget — safe
const { error } = await mutate(data); // await — also safe, no try/catch

// Invalidation — just pass the chunks
invalidates: [postsChunk];

What's next?

On this page