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. |
||
|---|---|---|
| example/src | ||
| src | ||
| test | ||
| .gitignore | ||
| .npmrc | ||
| dprint.jsonc | ||
| jsr.json | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| readme.changes.md | ||
| readme.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
@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
useMutatecall does not observeisError, 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 theuseMutatedocs 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'scontext, spread. With React Query this isgetandclient. helpers- is the return type ofgetOptimisticHelpers(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 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 queryupdateExisting- overwrite an entire query only if it existsremoveQuery- delete a query, but restore and refetch when rolled back.- For queries that resolve to arrays:
arrayPush- add items to the endarrayUnshift- add items to the startarrayRemove- remove items by afilterfunctionarrayFilter- preserve items by afilterfunctionarrayUpdate- update items by afilter+updatefunctionarrayInsertIndex- insert an item at an index
- Queries that are complex objects. Each function takes a type-safe object path to
evaluate.
objSet- 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 byfilterobjArrayFilter- preserve items from array byfilterobjArrayUpdate- update items in array byfilter+updateobjArrayInsertIndex- 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(..., { ... })
- With extra callbacks:
- 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>
</>;