| 1 | # `@clo/react-mutation` |
| 2 | |
| 3 | Install via [JSR](https://jsr.io/@clo/react-mutation): `npx jsr add @clo/react-mutation` |
| 4 | |
| 5 | ## Motivation |
| 6 | |
| 7 | At work, we found React Query, with a few helper functions, to be extremely |
| 8 | useful for fetching and synchronizing dynamic state in the browser. However, |
| 9 | their mutation story falls apart, is confusing, and misses a few obvious |
| 10 | features. Additionally, coworkers using AI agents continue to propagate bad |
| 11 | patterns 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 | |
| 22 | On our work repository, switching to React Mutation reduced the line count of our mutations in half (rough estimate). |
| 23 | |
| 24 | ## Setup |
| 25 | |
| 26 | React Mutation starts with a `MutationClient`, which shares global state for an application. |
| 27 | |
| 28 | ```ts |
| 29 | import { showToastUI } from "..."; |
| 30 | import { MutationClient } from "@clo/react-mutation"; |
| 31 | import { boundQueryClientGet, queryClientOptimisticHelpers, reactiveFromQueryCache } from "@clo/react-mutation/tanstack-query"; |
| 32 | import { QueryClient } from "@tanstack/react-query"; |
| 33 | |
| 34 | const queryClient = new QueryClient(); |
| 35 | export 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 | |
| 94 | With a mutation client, you can declare mutations with `mutations.define()`. |
| 95 | Start with the API call code, and then add an optimistic updater function. |
| 96 | |
| 97 | ```tsx |
| 98 | const queryItemList = queryOptions({ ... }); |
| 99 | const queryItem = (id: string) => queryOptions({ ... }); |
| 100 | |
| 101 | // The convention is to name handlers starting with `mut` |
| 102 | const 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. |
| 152 | export 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 | |
| 165 | The `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 | |
| 176 | When using React Query, you can opt into some incredible helpers for making it |
| 177 | very easy to write optimistic updates. Our setup at work starts with this client |
| 178 | configuration. |
| 179 | |
| 180 | ```ts |
| 181 | import { MutationClient } from "@clo/react-mutation"; |
| 182 | import { |
| 183 | boundQueryClientGet, |
| 184 | queryClientOptimisticHelpers, |
| 185 | } from "@clo/react-mutation/tanstack-query.ts"; |
| 186 | import { isServer } from "@tanstack/react-query"; |
| 187 | import { getQueryClient, makeNewQueryClient } from "./react-query-client"; |
| 188 | |
| 189 | const client = isServer ? makeNewQueryClient() : getQueryClient(); |
| 190 | export 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 | |
| 207 | Within optimistic updates, a `helpers` object is provided with many useful |
| 208 | helper functions. All helper functions take a `QueryKeyAndFn` (return type of |
| 209 | TanStack Query's `queryOptions`), and will track every query touched to |
| 210 | automatically 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 | |
| 240 | By default, a mutation will block the UI (by setting isPending). If you add |
| 241 | `debounceMs`, the mutation will no longer set isPending. Consecutive mutations |
| 242 | will override the earlier calls by rolling back the optimistic state. |
| 243 | |
| 244 | ```tsx |
| 245 | const 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 |
| 259 | function 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 | |
| 277 | For operations that might be passed a parameter that doesn't actually change |
| 278 | anything, `snapshot` can be used to detect no-op mutations. |
| 279 | |
| 280 | ```tsx |
| 281 | const 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 | |
| 301 | Three 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 | |
| 310 | The `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 | |
| 326 | The object uses getters to determine which fields should be subscribed to reduce |
| 327 | re-renders, but this is also used to determine how errors should be propagated. |
| 328 | If the error is observed by the component, then React Mutation will know not to |
| 329 | invoke the global error handler. Same for success. |
| 330 | |
| 331 | ```ts |
| 332 | const { errorMessage, isSuccess, run: run1 } = useMutate(...); // local handling in the form |
| 333 | const { run: run2 } = useMutate(...); // global handling with alerts |
| 334 | |
| 335 | return ( |
| 336 | <> |
| 337 | <button onClick={() => run1(...)}>local</button> |
| 338 | {isSuccess ? "you win!" : errorMessage} |
| 339 | |
| 340 | <button onClick={() => run2(...)}>global</button> |
| 341 | <> |
| 342 | ); |
| 343 | ``` |
| 344 | |
| 345 | When `null` is passed as the mutation, the `run` function is a disabled no-op. |
| 346 | |
| 347 | ### Mutation Buttons |
| 348 | |
| 349 | You can wrap your button component with `createMutationButton` to make it support mutations |
| 350 | |
| 351 | ```tsx |
| 352 | function 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 | } |
| 370 | export const MutationButton = createMutationButton(MutationButtonBase); |
| 371 | ``` |
| 372 | |
| 373 | It 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 | |
| 397 | Once the `MutationClient` is connected to the application's authentication |
| 398 | system, 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 |
| 406 | while signed out to build the `action.description` given to the sign-in flow. |
| 407 | |
| 408 | ```tsx |
| 409 | const 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 | |
| 422 | Scopes can allow easily adding permission gates. Unlike `auth: true`, a scoped |
| 423 | mutation whose scope is unsatisfied always disables; it is never routed to |
| 424 | `handleUnauthenticated`. |
| 425 | |
| 426 | ```tsx |
| 427 | const mutBanUser = mutations.define({ |
| 428 | id: "user/ban", |
| 429 | auth: "admin", |
| 430 | async mutate(targetId: string) {/* mutation */}, |
| 431 | // (...the rest...) |
| 432 | }); |
| 433 | ``` |
| 434 | |
| 435 | By default, `MutationButton` will hide non-allowed mutations that cannot route |
| 436 | to the sign-in flow, which can be opted out by passing the `showNotAllowed` |
| 437 | prop. |