@clo/react-mutationInstall via JSR: npx jsr add @clo/react-mutation
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.
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.On our work repository, switching to React Mutation reduced the line count of our mutations in half (rough estimate).
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, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query";
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(queryClient),
// 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);
},
// Optionally, your session system can be integrated to provide `auth: true`
// mutations that require a sign in before enabling. When a mutation cannot
// be performed due to missing auth, its `isAllowed` field reads false.
userContext: reactiveFromQueryCache(
queryClient,
queryCurrentUser,
(user) => user ? { user } : null,
),
// Optionally, on top of `userContext`, custom subsets of authentication can be
// defined for different permission levels, for example admin-only. This is used
// at the call site with `auth: "admin"`.
authScopes: {
admin: reactiveFromQueryCache(queryClient, queryCurrentUser, (user) => !!user?.isAdmin),
},
// On top of `userContext`, the session system can integrate its login flow to
// the mutation system. When configured, all authenticated mutations will be
// marked enabled but `!isAllowed`, except ones in scopes (so an admin
// mutation is still disabled and not allowed). When triggering a mutation, it
// is routed to this function instead.
handleUnauthenticated(action, mutation) {
// `action` is serializable. Could commit it to `sessionStorage` to survive
// a full-page sign-in flow, for example.
openLoginModal(`Sign in to ${action.description}`, () => {
mutations.run(action);
});
},
});
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({
// A stable identifier, unique per application.
id: "item/delete",
// `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.
// On success, the default behavior is to also re-fetch queries.
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 this optimistic handler above is perfect, we can decide to disable
// success-based refetching. This is default so that more things just work.
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>);
}
The optimistic function is given an object with the following APIs
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 mutatoronSuccess - add a callback to update queries after a successonRestore - add a callback to revert your optimistic updateonRefetch - add a callback to fetch data after a successWhen 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 queryupdateExisting - overwrite an entire query only if it existsremoveQuery - delete a query, but restore and refetch when rolled back.arrayPush - add items to the endarrayUnshift - add items to the startarrayRemove - remove items by a filter functionarrayFilter - preserve items by a filter functionarrayUpdate - update items by a filter + update functionarrayUpsert - update items by a filter, or insert when there is no matcharrayInsertIndex - insert an item at an indexobjSet - set a propertyobjSetMany - set many properties at onceobjIncrement - increment a numberobjDecrement - decrement a numberobjToggle - toggle a booleanobjArrayPush - add items to the end of an arrayobjArrayUnshift - add items to the start of an arrayobjArrayRemove - remove items from array by filterobjArrayFilter - preserve items from array by filterobjArrayUpdate - update items in array by filter + updateobjArrayUpsert - update items in array by filter, or insert when there is no matchobjArrayInsertIndex - insert an item in an array at an indexBy 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({
id: "item/update-field",
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}
);
}
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({
id: "item/update-field",
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...)
});
Three methods exist for calling mutations:
mutDoAction.run()
mutDoAction.runWithOptions(..., { ... })useMutate(mutDoAction)<MutationButton>useMutate HookThe 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.isDisabled (boolean) if the underlying form/button should be disabledisAllowed (boolean) if the mutation is allowed considering authentication and pre-checksisSuccess (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.
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>
</>;
Once the MutationClient is connected to the application's authentication
system, mutations themselves can declare auth: true. This does two things:
useMutate and mutation buttons read isAllowed: false while signed out.
Without a handleUnauthenticated handler they also disable; with one they
stay enabled so a click can route to the sign-in flow.UserContext to utilize.describe is the one function whose user context is nullable: it also runs
while signed out to build the action.description given to the sign-in flow.
const mutUpdateBio = mutations.define({
id: "user/update-bio",
auth: true,
async mutate(bio: string) {
this.user; // if the user context, if needed
},
optimistic({ args: [bio], helpers }) {
helpers.objSet(queryCurrentUser(), ["bio"], bio);
},
// (...the rest...)
});
Scopes can allow easily adding permission gates. Unlike auth: true, a scoped
mutation whose scope is unsatisfied always disables; it is never routed to
handleUnauthenticated.
const mutBanUser = mutations.define({
id: "user/ban",
auth: "admin",
async mutate(targetId: string) {/* mutation */},
// (...the rest...)
});
By default, MutationButton will hide non-allowed mutations that cannot route
to the sign-in flow, which can be opted out by passing the showNotAllowed
prop.