1# `@clo/react-mutation`
2
3Install via [JSR](https://jsr.io/@clo/react-mutation): `npx jsr add @clo/react-mutation`
4
5## Motivation
6
7At work, we found React Query, with a few helper functions, to be extremely
8useful for fetching and synchronizing dynamic state in the browser. However,
9their mutation story falls apart, is confusing, and misses a few obvious
10features. Additionally, coworkers using AI agents continue to propagate bad
11patterns and verbose code that is hard to review.
12
13- **Automatic result handling**. If a `useMutate` call does not observe
14 `isError`, unhandled errors will be propagated to a global handler, which can
15 display a UI toast. Otherwise, the component can display the error locally. How this works is explained in [the `useMutate` docs section](#the-usemutate-hook).
16- **Optimistic helpers with built-in rollbacks** make it super easy to alter the
17 UI without worrying about bugged error states. The
18 [built in helpers for React Query](#react-query-optimistic-helpers) shows
19 this power in more detail.
20- **Extra treats** such as [debouncing](#debounced-mutations) (to reduce repeated API calls changing a state back and forth) and [no-op snapshots](#snapshotting-to-skip-no-ops) (to detect when the state has not actually changed and no API call is necessary).
21
22On our work repository, switching to React Mutation reduced the line count of our mutations in half (rough estimate).
23
24## Setup
25
26React Mutation starts with a `MutationClient`, which shares global state for an application.
27
28```ts
29import { showToastUI } from "...";
30import { MutationClient } from "@clo/react-mutation";
31import { boundQueryClientGet, queryClientOptimisticHelpers, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query";
32import { QueryClient } from "@tanstack/react-query";
33
34const queryClient = new QueryClient();
35export const mutations = new MutationClient({
36 // All properties in `context` are available within every function.
37 context: {
38 client: queryClient,
39 get: boundQueryClientGet(queryClient),
40
41 // Can add any easy helpers for your codebase.
42 navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ...,
43 },
44
45 // Optimistic helpers are a second type of context, only available within
46 // optimistic update functions. These functions are bound to each mutation,
47 // which means they can handle automatic rollbacks and query invalidation.
48 getOptimisticHelpers: queryClientOptimisticHelpers(queryClient),
49
50 // When call sites do not opt into handling errors, or a pending
51 // mutation hook is unmounted, errors are sent to this function.
52 // An example is to bind this to global a UI toast.
53 reportError(userFriendlyErrorMessage: string, error: unknown) {
54 showToastUI("error", userFriendlyErrorMessage);
55 console.error(error); // or send to telemetry
56 },
57
58 // Similarly, when call sites do opt into handling success.
59 reportSuccess(userFriendlySuccessMessage: string) {
60 showToastUI("success", userFriendlyErrorMessage);
61 },
62
63 // Optionally, your session system can be integrated to provide `auth: true`
64 // mutations that require a sign in before enabling. When a mutation cannot
65 // be performed due to missing auth, its `isAllowed` field reads false.
66 userContext: reactiveFromQueryCache(
67 queryClient,
68 queryCurrentUser,
69 (user) => user ? { user } : null,
70 ),
71 // Optionally, on top of `userContext`, custom subsets of authentication can be
72 // defined for different permission levels, for example admin-only. This is used
73 // at the call site with `auth: "admin"`.
74 authScopes: {
75 admin: reactiveFromQueryCache(queryClient, queryCurrentUser, (user) => !!user?.isAdmin),
76 },
77 // On top of `userContext`, the session system can integrate its login flow to
78 // the mutation system. When configured, all authenticated mutations will be
79 // marked enabled but `!isAllowed`, except ones in scopes (so an admin
80 // mutation is still disabled and not allowed). When triggering a mutation, it
81 // is routed to this function instead.
82 handleUnauthenticated(action, mutation) {
83 // `action` is serializable. Could commit it to `sessionStorage` to survive
84 // a full-page sign-in flow, for example.
85 openLoginModal(`Sign in to ${action.description}`, () => {
86 mutations.run(action);
87 });
88 },
89});
90```
91
92## Declaring Mutations
93
94With a mutation client, you can declare mutations with `mutations.define()`.
95Start with the API call code, and then add an optimistic updater function.
96
97```tsx
98const queryItemList = queryOptions({ ... });
99const queryItem = (id: string) => queryOptions({ ... });
100
101// The convention is to name handlers starting with `mut`
102const mutDeleteItem = mutations.define({
103 // A stable identifier, unique per application.
104 id: "item/delete",
105 // `mutate` comes first (for type inference), and
106 // is only worried about syncing with the backend.
107 async mutate(id: string) {
108 const response = await fetch(`/items/${id}`, { method: "delete" });
109 if (!response.ok) throw new Error(`HTTP ${response.status}`);
110 return response.json();
111 },
112
113 // `optimistic` is provided a `helpers` object which implement automatic rollbacks.
114 optimistic({ client, get, helpers, args: [id], onSuccess, onRestore, onRefetch }) {
115 // Remove the items matching the filter, but restore and refetch them on failure.
116 // On success, the default behavior is to also re-fetch queries.
117 helpers.arrayRemove(queryItemList, (item) => item === id);
118 // Set a property on an object, also restores and refetches. The `obj`
119 // helpers implement a type-safe object path system for nested fields.
120 helpers.objSet(queryItem(id), ["deleted"], true);
121
122 onSuccess((result) => {
123 // Remove this query from the client
124 helpers.removeQuery(queryItem(id));
125 });
126
127 onRestore(() => {}); // to restore non React Query state
128 onRefetch(() => {}); // to refetch non React Query state
129
130 // For React query specifically, there is a helper for refetching.
131 // internally, this is called from every other helper, and de-duplicates
132 // repeated calls so it only refetches once. This can be helpful if the data
133 // is only updated in `onSuccess` or the query is related in some way but
134 // doesn't have an optimistic update.
135 helpers.refetchOnSettled(queryItemList);
136 },
137
138 // These strings are shown in error/success messages, called *after* optimistic state is applied.
139 // Example: `Could not {description}`
140 describe: ({ get, args: [id] }) =>
141 `Delete '${get(queryItem(id))?.title ?? "Unknown Item"}'`,
142 // Example: `Successfully {description}`
143 describeResult: ({ get, args: [id] }) => "Deleted Item",
144
145 // Since this optimistic handler above is perfect, we can decide to disable
146 // success-based refetching. This is default so that more things just work.
147 refetchOnSuccess: false,
148});
149
150// React example. Since `error` and `result` are not destructed, messages are
151// indicated through UI toasts from the mutation client.
152export function Example({ id }: { id: string }) {
153 const { data: list } = useSuspenseQuery(queryItemList);
154 const { run, /* isPending, result, error, ... */ } = useMutate(mutDeleteItem);
155
156 return list.map((id) => <li key={id}>
157 <Item id={id} />
158 <button onClick={() => run(id)}>delete</button>
159 </li>);
160}
161```
162
163## Optimistic Updates
164
165The `optimistic` function is given an object with the following APIs
166
167- All values from `MutationClient`'s `context`, spread. With React Query this is `get` and `client`.
168- `helpers` - is the return type of `getOptimisticHelpers` (see next section)
169- `args` - which is the arguments passed to the mutator
170- `onSuccess` - add a callback to update queries after a success
171- `onRestore` - add a callback to revert your optimistic update
172- `onRefetch` - add a callback to fetch data after a success
173
174#### React Query Optimistic Helpers
175
176When using React Query, you can opt into some incredible helpers for making it
177very easy to write optimistic updates. Our setup at work starts with this client
178configuration.
179
180```ts
181import { MutationClient } from "@clo/react-mutation";
182import {
183 boundQueryClientGet,
184 queryClientOptimisticHelpers,
185} from "@clo/react-mutation/tanstack-query.ts";
186import { isServer } from "@tanstack/react-query";
187import { getQueryClient, makeNewQueryClient } from "./react-query-client";
188
189const client = isServer ? makeNewQueryClient() : getQueryClient();
190export const mutations = new MutationClient({
191 enabled: !isServer, // `enabled: false` prevents mutations from running
192 context: {
193 client,
194 get: boundQueryClientGet(client),
195 },
196 getOptimisticHelpers: queryClientOptimisticHelpers(client),
197 // `showAlert` is our global toast function
198 reportError(message) {
199 showAlert(message, "error");
200 },
201 reportSuccess(message: string) {
202 showAlert(message, "success");
203 },
204});
205```
206
207Within optimistic updates, a `helpers` object is provided with many useful
208helper functions. All helper functions take a `QueryKeyAndFn` (return type of
209TanStack Query's `queryOptions`), and will track every query touched to
210automatically implement `onRefetch` and `onRestore` callbacks. The current list of them is:
211
212- `set` - overwrite an entire query
213- `updateExisting` - overwrite an entire query only if it exists
214- `removeQuery` - delete a query, but restore and refetch when rolled back.
215- For queries that resolve to arrays:
216 - `arrayPush` - add items to the end
217 - `arrayUnshift` - add items to the start
218 - `arrayRemove` - remove items by a `filter` function
219 - `arrayFilter` - preserve items by a `filter` function
220 - `arrayUpdate` - update items by a `filter` + `update` function
221 - `arrayUpsert` - update items by a `filter`, or insert when there is no match
222 - `arrayInsertIndex` - insert an item at an index
223- Queries that are complex objects. Each function takes a type-safe object path to
224 evaluate.
225 - `objSet` - set a property
226 - `objSetMany` - set many properties at once
227 - `objIncrement` - increment a number
228 - `objDecrement` - decrement a number
229 - `objToggle` - toggle a boolean
230 - `objArrayPush` - add items to the end of an array
231 - `objArrayUnshift` - add items to the start of an array
232 - `objArrayRemove` - remove items from array by `filter`
233 - `objArrayFilter` - preserve items from array by `filter`
234 - `objArrayUpdate` - update items in array by `filter` + `update`
235 - `objArrayUpsert` - update items in array by `filter`, or insert when there is no match
236 - `objArrayInsertIndex` - insert an item in an array at an index
237
238## Debouncing
239
240By default, a mutation will block the UI (by setting isPending). If you add
241`debounceMs`, the mutation will no longer set isPending. Consecutive mutations
242will override the earlier calls by rolling back the optimistic state.
243
244```tsx
245const mutUpdateField = mutations.define({
246 id: "item/update-field",
247 async mutate(id: string, value: string) { /* mutation */ },
248 optimistic({ args: [id, value], helpers }) {
249 helpers.objSet(queryItem(id), ["value"], value);
250 },
251 // (...describe functions...)
252
253 debounceMs: 500, // wait 0.5 seconds before mutating
254 key: ({ args: [id] }) => id, // place same `ids` into the same timer group
255 // debounceImmediate: true, // can also support leading edge, good for buttons
256});
257
258// React example - Auto-saving text field
259function Item({ id }: { id: string }) {
260 const { data: item } = useSuspenseQuery(queryItem(id));
261 const { run, isSuccess } = useMutate(mutUpdateField);
262
263 return (
264 <input
265 value={item.value}
266 onChange={(e) => {
267 mutUpdateField.run(id, e.target.value);
268 }}
269 />
270 {isSuccess ? "Saved" : null}
271 );
272}
273```
274
275## Snapshotting to Skip No-Ops
276
277For operations that might be passed a parameter that doesn't actually change
278anything, `snapshot` can be used to detect no-op mutations.
279
280```tsx
281const mutUpdateField = mutations.define({
282 id: "item/update-field",
283 async mutate(id: string, value: string) {/* mutation */},
284
285 optimistic({ args: [id, value], helpers }) {
286 helpers.objSet(queryItem(id), ["value"], value);
287 },
288
289 // called once before `optimistic` and once after. if the values are equal,
290 // then the mutation is cancelled (won't call `onSuccess`, but will `onSettled`)
291 // (defaulting to a json-based deep equal check, customize in MutationClient)
292 snapshot({ args: [id], get }) {
293 return get(queryItem(id))?.value;
294 },
295 // (...describe and optionally debounce stuff...)
296});
297```
298
299## Calling Mutations
300
301Three methods exist for calling mutations:
302
303- Directly on the mutation: `mutDoAction.run()`
304 - With extra callbacks: `mutDoAction.runWithOptions(..., { ... })`
305- From a React component: `useMutate(mutDoAction)`
306- From a React Element: `<MutationButton>`
307
308### The `useMutate` Hook
309
310The `useMutate(null | Mutation)` react hook returns an object with the following properties.
311
312- `run` (Function) this starts the mutation.
313- `clear` (Function) clear the status of sucess or error states.
314- `isPending` (boolean) if a loading indicator should be visible.
315- `isDisabled` (boolean) if the underlying form/button should be disabled
316- `isAllowed` (boolean) if the mutation is allowed considering authentication and pre-checks
317- `isSuccess` (boolean) if the mutation has succeeded.
318- `result` (Result or undefined) the successful result of the mutation.
319- `isError` (boolean) if the mutation failed.
320- `errorMessage` (string or undefined) a friendly error message.
321- `error` (unknown) the error value of the mutation.
322- `isMutating` (boolean) if a mutation function is currently running.
323- `isOptimisticData` (boolean) if cache data is optimistic.
324- `status`: a string enum of the mutation status.
325
326The object uses getters to determine which fields should be subscribed to reduce
327re-renders, but this is also used to determine how errors should be propagated.
328If the error is observed by the component, then React Mutation will know not to
329invoke the global error handler. Same for success.
330
331```ts
332const { errorMessage, isSuccess, run: run1 } = useMutate(...); // local handling in the form
333const { run: run2 } = useMutate(...); // global handling with alerts
334
335return (
336 <>
337 <button onClick={() => run1(...)}>local</button>
338 {isSuccess ? "you win!" : errorMessage}
339
340 <button onClick={() => run2(...)}>global</button>
341 <>
342);
343```
344
345When `null` is passed as the mutation, the `run` function is a disabled no-op.
346
347### Mutation Buttons
348
349You can wrap your button component with `createMutationButton` to make it support mutations
350
351```tsx
352function MutationButtonBase({
353 isPending,
354 disabled,
355 children,
356 ...args
357}: {
358 isPending: boolean;
359 iconButton?: boolean;
360} & ButtonProps) {
361 return (
362 <Button {...args} disabled={disabled || isPending}>
363 <div className="flex items-center gap-2">
364 {isPending && <Loader2 className="mr-1 size-4 animate-spin" />}
365 {(!args.iconButton || !isPending) && children}
366 </div>
367 </Button>
368 );
369}
370export const MutationButton = createMutationButton(MutationButtonBase);
371```
372
373It can now be used for easy mutations:
374
375```tsx
376<>
377 {/* Static Arguments */}
378 <MutationButton mutation={mutToggleFollow} args={[userId]}>
379 Follow
380 </MutationButton>
381
382 {/* Dynamic Arguments */}
383 <MutationButton
384 mutation={mutSendMessage}
385 args={(e) => {
386 if (Math.random() < 0.5) e.preventDefault(); // prevent the submit
387 return [userId, messageContent];
388 }}
389 >
390 Send Message
391 </MutationButton>
392</>;
393```
394
395## Authenticated Mutations
396
397Once the `MutationClient` is connected to the application's authentication
398system, mutations themselves can declare `auth: true`. This does two things:
399
400- `useMutate` and mutation buttons read `isAllowed: false` while signed out.
401 Without a `handleUnauthenticated` handler they also disable; with one they
402 stay enabled so a click can route to the sign-in flow.
403- The mutation implementation is given the `UserContext` to utilize.
404
405`describe` is the one function whose user context is nullable: it also runs
406while signed out to build the `action.description` given to the sign-in flow.
407
408```tsx
409const mutUpdateBio = mutations.define({
410 id: "user/update-bio",
411 auth: true,
412 async mutate(bio: string) {
413 this.user; // if the user context, if needed
414 },
415 optimistic({ args: [bio], helpers }) {
416 helpers.objSet(queryCurrentUser(), ["bio"], bio);
417 },
418 // (...the rest...)
419});
420```
421
422Scopes can allow easily adding permission gates. Unlike `auth: true`, a scoped
423mutation whose scope is unsatisfied always disables; it is never routed to
424`handleUnauthenticated`.
425
426```tsx
427const mutBanUser = mutations.define({
428 id: "user/ban",
429 auth: "admin",
430 async mutate(targetId: string) {/* mutation */},
431 // (...the rest...)
432});
433```
434
435By default, `MutationButton` will hide non-allowed mutations that cannot route
436to the sign-in flow, which can be opted out by passing the `showNotAllowed`
437prop.