create async mutations with trivial optimistic updates and great error handling
Find a file
clover caruso 2f39b6a4fe
fix: pass context to optimistic handlers
honestly really sus that ts didnt catch this bug. but i consider it a
success that we had so many mutations at work that didn't requires
directly accessing context in an optimistic updater -- it means the
helpers pattern is too goated.
2026-02-12 16:11:07 -08:00
example/src autofmt 2026-01-29 22:05:20 -08:00
src fix: pass context to optimistic handlers 2026-02-12 16:11:07 -08:00
test fix: pass context to optimistic handlers 2026-02-12 16:11:07 -08:00
.gitignore feat: the library mostly exists now 2026-01-27 18:45:35 -08:00
.npmrc feat: the library mostly exists now 2026-01-27 18:45:35 -08:00
dprint.jsonc autofmt 2026-01-29 22:05:20 -08:00
jsr.json fix: pass context to optimistic handlers 2026-02-12 16:11:07 -08:00
LICENSE feat: prepare beta 1 for jsr 2026-01-27 18:45:36 -08:00
package-lock.json fix: a ton of bugs because i didn't write tests 2026-01-30 17:18:24 -08:00
package.json fix: a ton of bugs because i didn't write tests 2026-01-30 17:18:24 -08:00
readme.changes.md fix: pass context to optimistic handlers 2026-02-12 16:11:07 -08:00
readme.md chore: readme maxing 2026-01-30 17:18:24 -08:00
tsconfig.json some more test cases 2026-01-29 22:37:58 -08:00
vitest.config.ts some more test cases 2026-01-29 22:37:58 -08:00

@clo/react-mutation

Install via JSR: npx jsr add @clo/react-mutation

Motivation

At work, we found React Query, with a few helper functions, to be extremely useful for fetching and synchronizing dynamic state in the browser. However, their mutation story falls apart, is confusing, and misses a few obvious features. Additionally, coworkers using AI agents continue to propagate bad patterns and verbose code that is hard to review.

  • Automatic result handling. If a useMutate call does not observe isError, unhandled errors will be propagated to a global handler, which can display a UI toast. Otherwise, the component can display the error locally. How this works is explained in the useMutate docs section.
  • Optimistic helpers with built-in rollbacks make it super easy to alter the UI without worrying about bugged error states. The built in helpers for React Query shows this power in more detail.
  • Extra treats such as debouncing (to reduce repeated API calls changing a state back and forth) and no-op snapshots (to detect when the state has not actually changed and no API call is necessary).

On our work repository, switching to React Mutation reduced the line count of our mutations in half (rough estimate).

Setup

React Mutation starts with a MutationClient, which shares global state for an application.

import { showToastUI } from "...";
import { MutationClient } from "@clo/react-mutation";
import {
  boundQueryClientGet,
  queryClientOptimisticHelpers,
} from "@clo/react-mutation";
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();
export const mutations = new MutationClient({
  // All properties in `context` are available within every function.
  context: {
    client: queryClient,
    get: boundQueryClientGet(client),
    
    // Can add any easy helpers for your codebase.
    navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ...,
  },

  // Optimistic helpers are a second type of context, only available within
  // optimistic update functions. These functions are bound to each mutation,
  // which means they can handle automatic rollbacks and query invalidation.
  getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),

  // When call sites do not opt into handling errors, or a pending
  // mutation hook is unmounted, errors are sent to this function.
  // An example is to bind this to global a UI toast.
  reportError(userFriendlyErrorMessage: string, error: unknown) {
    showToastUI("error", userFriendlyErrorMessage);
    console.error(error); // or send to telemetry
  },

  // Similarly, when call sites do opt into handling success.
  reportSuccess(userFriendlySuccessMessage: string) {
    showToastUI("success", userFriendlyErrorMessage);
  },
});

Declaring Mutations

With a mutation client, you can declare mutations with mutations.define(). Start with the API call code, and then add an optimistic updater function.

const queryItemList = queryOptions({ ... });
const queryItem = (id: string) => queryOptions({ ... });

// The convention is to name handlers starting with `mut`
const mutDeleteItem = mutations.define({
  // `mutate` comes first (for type inference), and
  // is only worried about syncing with the backend.
  async mutate(id: string) {
    const response = await fetch(`/items/${id}`, { method: "delete" });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  },
  
  // `optimistic` is provided a `helpers` object which implement automatic rollbacks.
  optimistic({ client, get, helpers, args: [id], onSuccess, onRestore, onRefetch }) {
    // Remove the items matching the filter, but restore and refetch them on failure.
    helpers.arrayRemove(queryItemList, (item) => item === id);
    // Set a property on an object, also restores and refetches. The `obj`
    // helpers implement a type-safe object path system for nested fields.
    helpers.objSet(queryItem(id), ["deleted"], true);
    
    onSuccess((result) => {
      // Remove this query from the client
      helpers.removeQuery(queryItem(id));
    });
    
    onRestore(() => {}); // to restore non React Query state
    onRefetch(() => {}); // to refetch non React Query state
    
    // For React query specifically, there is a helper for refetching.
    // internally, this is called from every other helper, and de-duplicates
    // repeated calls so it only refetches once. This can be helpful if the data
    // is only updated in `onSuccess` or the query is related in some way but
    // doesn't have an optimistic update.
    helpers.refetchOnSettled(queryItemList);
  },
  
  // These strings are shown in error/success messages, called *after* optimistic state is applied.
  // Example: `Could not {description}`
  describe: ({ get, args: [id] }) => 
    `Delete '${get(queryItem(id))?.title ?? "Unknown Item"}'`,
  // Example: `Successfully {description}`
  describeResult: ({ get, args: [id] }) => "Deleted Item",
  
  // Since the optimistic handler is perfect, there is no need to refetch any
  // data once a success case is hit. This defaults to false for simplicity.
  refetchOnSuccess: false,
});

// React example. Since `error` and `result` are not destructed, messages are
// indicated through UI toasts from the mutation client.
export function Example({ id }: { id: string }) {
  const { data: list } = useSuspenseQuery(queryItemList);
  const { run, /* isPending, result, error, ... */ } = useMutate(mutDeleteItem);

  return list.map((id) => <li key={id}>
    <Item id={id} />
    <button onClick={() => run(id)}>delete</button>
  </li>);
}

Optimistic Updates

The optimistic function is given an object with the following APIs

  • All values from MutationClient's context, spread. With React Query this is get and client.
  • helpers - is the return type of getOptimisticHelpers (see next section)
  • args - which is the arguments passed to the mutator
  • onSuccess - add a callback to update queries after a success
  • onRestore - add a callback to revert your optimistic update
  • onRefetch - add a callback to fetch data after a success

React Query Optimistic Helpers

When using React Query, you can opt into some incredible helpers for making it very easy to write optimistic updates. Our setup at work starts with this client configuration.

import { MutationClient } from "@clo/react-mutation";
import {
  boundQueryClientGet,
  queryClientOptimisticHelpers,
} from "@clo/react-mutation/tanstack-query.ts";
import { isServer } from "@tanstack/react-query";
import { getQueryClient, makeNewQueryClient } from "./react-query-client";

const client = isServer ? makeNewQueryClient() : getQueryClient();
export const mutations = new MutationClient({
  enabled: !isServer, // `enabled: false` prevents mutations from running
  context: {
    client,
    get: boundQueryClientGet(client),
  },
  getOptimisticHelpers: queryClientOptimisticHelpers(client),
  // `showAlert` is our global toast function
  reportError(message) {
    showAlert(message, "error");
  },
  reportSuccess(message: string) {
    showAlert(message, "success");
  },
});

Within optimistic updates, a helpers object is provided with many useful helper functions. All helper functions take a QueryKeyAndFn (return type of TanStack Query's queryOptions), and will track every query touched to automatically implement onRefetch and onRestore callbacks. The current list of them is:

  • set - overwrite an entire query
  • updateExisting - overwrite an entire query only if it exists
  • removeQuery - delete a query, but restore and refetch when rolled back.
  • For queries that resolve to arrays:
    • arrayPush - add items to the end
    • arrayUnshift - add items to the start
    • arrayRemove - remove items by a filter function
    • arrayFilter - preserve items by a filter function
    • arrayUpdate - update items by a filter + update function
    • arrayInsertIndex - insert an item at an index
  • Queries that are complex objects. Each function takes a type-safe object path to evaluate.
    • objSet - set a property
    • objSetMany - set many properties at once
    • objIncrement - increment a number
    • objDecrement - decrement a number
    • objToggle - toggle a boolean
    • objArrayPush - add items to the end of an array
    • objArrayUnshift - add items to the start of an array
    • objArrayRemove - remove items from array by filter
    • objArrayFilter - preserve items from array by filter
    • objArrayUpdate - update items in array by filter + update
    • objArrayInsertIndex - insert an item in an array at an index

Debouncing

By default, a mutation will block the UI (by setting isPending). If you add debounceMs, the mutation will no longer set isPending. Consecutive mutations will override the earlier calls by rolling back the optimistic state.

const mutUpdateField = mutations.define({
  async mutate(id: string, value: string) { /* mutation */ },
  optimistic({ args: [id, value], helpers }) {
    helpers.objSet(queryItem(id), ["value"], value);
  },
  // (...describe functions...)

  debounceMs: 500, // wait 0.5 seconds before mutating
  key: ({ args: [id] }) => id, // place same `ids` into the same timer group
  // debounceImmediate: true, // can also support leading edge, good for buttons
});

// React example - Auto-saving text field
function Item({ id }: { id: string }) {
  const { data: item } = useSuspenseQuery(queryItem(id));
  const { run, isSuccess } = useMutate(mutUpdateField);

  return (
    <input
      value={item.value}
      onChange={(e) => {
        mutUpdateField.run(id, e.target.value);
      }}
    />
    {isSuccess ? "Saved" : null}
  );
}

Snapshotting to Skip No-Ops

For operations that might be passed a parameter that doesn't actually change anything, snapshot can be used to detect no-op mutations.

const mutUpdateField = mutations.define({
  async mutate(id: string, value: string) {/* mutation */},

  optimistic({ args: [id, value], helpers }) {
    helpers.objSet(queryItem(id), ["value"], value);
  },

  // called once before `optimistic` and once after. if the values are equal,
  // then the mutation is cancelled (won't call `onSuccess`, but will `onSettled`)
  // (defaulting to a json-based deep equal check, customize in MutationClient)
  snapshot({ args: [id], get }) {
    return get(queryItem(id))?.value;
  },
  // (...describe and optionally debounce stuff...)
});

Calling Mutations

Three methods exist for calling mutations:

  • Directly on the mutation: mutDoAction.run()
    • With extra callbacks: mutDoAction.runWithOptions(..., { ... })
  • From a React component: useMutate(mutDoAction)
  • From a React Element: <MutationButton>

The useMutate Hook

The useMutate(null | Mutation) react hook returns an object with the following properties.

  • run (Function) this starts the mutation.
  • clear (Function) clear the status of sucess or error states.
  • isPending (boolean) if a loading indicator should be visible.
  • isSuccess (boolean) if the mutation has succeeded.
  • result (Result or undefined) the successful result of the mutation.
  • isError (boolean) if the mutation failed.
  • errorMessage (string or undefined) a friendly error message.
  • error (unknown) the error value of the mutation.
  • isMutating (boolean) if a mutation function is currently running.
  • isOptimisticData (boolean) if cache data is optimistic.
  • status: a string enum of the mutation status.

The object uses getters to determine which fields should be subscribed to reduce re-renders, but this is also used to determine how errors should be propagated. If the error is observed by the component, then React Mutation will know not to invoke the global error handler. Same for success.

const { errorMessage, isSuccess, run: run1 } = useMutate(...); // local handling in the form
const { run: run2 } = useMutate(...); // global handling with alerts

return (
  <>
    <button onClick={() => run1(...)}>local</button>
    {isSuccess ? "you win!" : errorMessage}
    
    <button onClick={() => run2(...)}>global</button>
  <>
);

When null is passed as the mutation, the run function is a disabled no-op.

Mutation Buttons

You can wrap your button component with createMutationButton to make it support mutations

function MutationButtonBase({
  isPending,
  disabled,
  children,
  ...args
}: {
  isPending: boolean;
  iconButton?: boolean;
} & ButtonProps) {
  return (
    <Button {...args} disabled={disabled || isPending}>
      <div className="flex items-center gap-2">
        {isPending && <Loader2 className="mr-1 size-4 animate-spin" />}
        {(!args.iconButton || !isPending) && children}
      </div>
    </Button>
  );
}
export const MutationButton = createMutationButton(MutationButtonBase);

It can now be used for easy mutations:

<>
  {/* Static Arguments */}
  <MutationButton mutation={mutToggleFollow} args={[userId]}>
    Follow
  </MutationButton>

  {/* Dynamic Arguments */}
  <MutationButton
    mutation={mutSendMessage}
    args={(e) => {
      if (Math.random() < 0.5) e.preventDefault(); // prevent the submit
      return [userId, messageContent];
    }}
  >
    Send Message
  </MutationButton>
</>;