| ... | @@ -10,16 +10,16 @@ their mutation story falls apart, is confusing, and misses a few obvious | ... | @@ -10,16 +10,16 @@ their mutation story falls apart, is confusing, and misses a few obvious |
| 10 | features. Additionally, coworkers using AI agents continue to propagate bad | 10 | features. Additionally, coworkers using AI agents continue to propagate bad |
| 11 | patterns and verbose code that is hard to review. | 11 | patterns and verbose code that is hard to review. |
| 12 | | 12 | |
| 13 | The primary gains React Mutation provides are: | 13 | - **Automatic result handling**. If a `useMutate` call does not observe |
| 14 | | | |
| 15 | - **Automatic result handling**. If a `useMutate` hook does not observe | | |
| 16 | `isError`, unhandled errors will be propagated to a global handler, which can | 14 | `isError`, unhandled errors will be propagated to a global handler, which can |
| 17 | display a UI toast. Otherwise, the component can display the error locally. | 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). |
| 18 | - **Optimistic helpers with built-in rollbacks** make it super easy to alter the | 16 | - **Optimistic helpers with built-in rollbacks** make it super easy to alter the |
| 19 | UI without worrying about bugged error states. The | 17 | UI without worrying about bugged error states. The |
| 20 | [built in helpers for React Query](#react-query-optimistic-helpers) shows | 18 | [built in helpers for React Query](#react-query-optimistic-helpers) shows |
| 21 | this power in more detail. | 19 | this power in more detail. |
| 22 | - Extra treats such as debouncing (toggle button spam) and no-op filters (auto-save text inputs). | 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 | | 23 | |
| 24 | ## Setup | 24 | ## Setup |
| 25 | | 25 | |
| ... | @@ -39,9 +39,10 @@ export const mutations = new MutationClient({ | ... | @@ -39,9 +39,10 @@ export const mutations = new MutationClient({ |
| 39 | // All properties in `context` are available within every function. | 39 | // All properties in `context` are available within every function. |
| 40 | context: { | 40 | context: { |
| 41 | client: queryClient, | 41 | client: queryClient, |
| | 42 | get: boundQueryClientGet(client), |
| | 43 | |
| 42 | // Can add any easy helpers for your codebase. | 44 | // Can add any easy helpers for your codebase. |
| 43 | // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`) | 45 | navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ..., |
| 44 | get: (k: QueryKey) => client.getQueryData(k), | | |
| 45 | }, | 46 | }, |
| 46 | | 47 | |
| 47 | // Optimistic helpers are a second type of context, only available within | 48 | // Optimistic helpers are a second type of context, only available within |
| ... | @@ -84,26 +85,35 @@ const mutDeleteItem = mutations.define({ | ... | @@ -84,26 +85,35 @@ const mutDeleteItem = mutations.define({ |
| 84 | }, | 85 | }, |
| 85 | | 86 | |
| 86 | // `optimistic` is provided a `helpers` object which implement automatic rollbacks. | 87 | // `optimistic` is provided a `helpers` object which implement automatic rollbacks. |
| 87 | optimistic({ client, get, helpers, args: [id], onSuccess }) { | 88 | optimistic({ client, get, helpers, args: [id], onSuccess, onRestore, onRefetch }) { |
| 88 | // Remove the matching items, but restore and refetch them on failure. | 89 | // Remove the items matching the filter, but restore and refetch them on failure. |
| 89 | helpers.arrayRemove(queryItemList, (item) => item === id); | 90 | helpers.arrayRemove(queryItemList, (item) => item === id); |
| 90 | // Remove this query from the client, but restore as stale and refetch it on failure. | 91 | // Set a property on an object, also restores and refetches. The `obj` |
| 91 | helpers.removeQuery(queryItem); | 92 | // helpers implement a type-safe object path system for nested fields. |
| | 93 | helpers.objSet(queryItem(id), ["deleted"], true); |
| 92 | | 94 | |
| 93 | onSuccess((result) => { | 95 | onSuccess((result) => { |
| 94 | // in general case, you may want to apply a success update | 96 | // Remove this query from the client |
| | 97 | helpers.removeQuery(queryItem(id)); |
| 95 | }); | 98 | }); |
| | 99 | |
| | 100 | onRestore(() => {}); // to restore non React Query state |
| | 101 | onRefetch(() => {}); // to refetch non React Query state |
| | 102 | |
| | 103 | // For React query specifically, there is a helper for refetching. |
| | 104 | // internally, this is called from every other helper, and de-duplicates |
| | 105 | // repeated calls so it only refetches once. This can be helpful if the data |
| | 106 | // is only updated in `onSuccess` or the query is related in some way but |
| | 107 | // doesn't have an optimistic update. |
| | 108 | helpers.refetchOnSettled(queryItemList); |
| 96 | }, | 109 | }, |
| 97 | | 110 | |
| 98 | // These strings are shown in error/success messages, called *after* optimistic state is applied. | 111 | // These strings are shown in error/success messages, called *after* optimistic state is applied. |
| 99 | // Example: `Could not {description}` | 112 | // Example: `Could not {description}` |
| 100 | describe({ get, args: [id] }) { | 113 | describe: ({ get, args: [id] }) => |
| 101 | const title = get(queryItem().queryKey)?.title ?? "Unknown Item"; | 114 | `Delete '${get(queryItem(id))?.title ?? "Unknown Item"}'`, |
| 102 | return `Delete '${get(queryItem(id))?.title ?? 'unknown'}'`; | | |
| 103 | }, | | |
| 104 | // Example: `Successfully {description}` | 115 | // Example: `Successfully {description}` |
| 105 | describeResult: ({ get, args: [id] }) => | 116 | describeResult: ({ get, args: [id] }) => "Deleted Item", |
| 106 | `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`, | | |
| 107 | | 117 | |
| 108 | // Since the optimistic handler is perfect, there is no need to refetch any | 118 | // Since the optimistic handler is perfect, there is no need to refetch any |
| 109 | // data once a success case is hit. This defaults to false for simplicity. | 119 | // data once a success case is hit. This defaults to false for simplicity. |
| ... | @@ -137,7 +147,7 @@ The `optimistic` function is given an object with the following APIs | ... | @@ -137,7 +147,7 @@ The `optimistic` function is given an object with the following APIs |
| 137 | #### React Query Optimistic Helpers | 147 | #### React Query Optimistic Helpers |
| 138 | | 148 | |
| 139 | When using React Query, you can opt into some incredible helpers for making it | 149 | When using React Query, you can opt into some incredible helpers for making it |
| 140 | very easy to write Optimistic Updates. Our setup at work is with this client | 150 | very easy to write optimistic updates. Our setup at work starts with this client |
| 141 | configuration. | 151 | configuration. |
| 142 | | 152 | |
| 143 | ```ts | 153 | ```ts |
| ... | @@ -157,6 +167,7 @@ export const mutations = new MutationClient({ | ... | @@ -157,6 +167,7 @@ export const mutations = new MutationClient({ |
| 157 | get: boundQueryClientGet(client), | 167 | get: boundQueryClientGet(client), |
| 158 | }, | 168 | }, |
| 159 | getOptimisticHelpers: queryClientOptimisticHelpers(client), | 169 | getOptimisticHelpers: queryClientOptimisticHelpers(client), |
| | 170 | // `showAlert` is our global toast function |
| 160 | reportError(message) { | 171 | reportError(message) { |
| 161 | showAlert(message, "error"); | 172 | showAlert(message, "error"); |
| 162 | }, | 173 | }, |
| ... | @@ -181,8 +192,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list | ... | @@ -181,8 +192,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list |
| 181 | - `arrayFilter` - preserve items by a `filter` function | 192 | - `arrayFilter` - preserve items by a `filter` function |
| 182 | - `arrayUpdate` - update items by a `filter` + `update` function | 193 | - `arrayUpdate` - update items by a `filter` + `update` function |
| 183 | - `arrayInsertIndex` - insert an item at an index | 194 | - `arrayInsertIndex` - insert an item at an index |
| 184 | - Queries that are complex objects. Each function takes a type-safe json path to | 195 | - Queries that are complex objects. Each function takes a type-safe object path to |
| 185 | evaluate, but this system currently has type bugs and is being improved. | 196 | evaluate. |
| 186 | - `objSet` - set a property | 197 | - `objSet` - set a property |
| 187 | - `objSetMany` - set many properties at once | 198 | - `objSetMany` - set many properties at once |
| 188 | - `objIncrement` - increment a number | 199 | - `objIncrement` - increment a number |
| ... | @@ -198,7 +209,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list | ... | @@ -198,7 +209,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list |
| 198 | ## Debouncing | 209 | ## Debouncing |
| 199 | | 210 | |
| 200 | By default, a mutation will block the UI (by setting isPending). If you add | 211 | By default, a mutation will block the UI (by setting isPending). If you add |
| 201 | `debounceMs`, the mutation will no longer set isPending. Multiple mutations | 212 | `debounceMs`, the mutation will no longer set isPending. Consecutive mutations |
| | 213 | will override the earlier calls by rolling back the optimistic state. |
| 202 | | 214 | |
| 203 | ```tsx | 215 | ```tsx |
| 204 | const mutUpdateField = mutations.define({ | 216 | const mutUpdateField = mutations.define({ |
| ... | @@ -208,15 +220,15 @@ const mutUpdateField = mutations.define({ | ... | @@ -208,15 +220,15 @@ const mutUpdateField = mutations.define({ |
| 208 | }, | 220 | }, |
| 209 | // (...describe functions...) | 221 | // (...describe functions...) |
| 210 | | 222 | |
| 211 | // debounce for 0.5 seconds, grouping items on their `id` | 223 | debounceMs: 500, // wait 0.5 seconds before mutating |
| 212 | debounceMs: 500, | 224 | key: ({ args: [id] }) => id, // place same `ids` into the same timer group |
| 213 | key: ({ args: [id] }) => id, | 225 | // debounceImmediate: true, // can also support leading edge, good for buttons |
| 214 | }); | 226 | }); |
| 215 | | 227 | |
| 216 | // React example - Auto-saving text field | 228 | // React example - Auto-saving text field |
| 217 | function Item({ id }: { id: string }) { | 229 | function Item({ id }: { id: string }) { |
| 218 | const { data: item } = useSuspenseQuery(queryItem(id)); | 230 | const { data: item } = useSuspenseQuery(queryItem(id)); |
| 219 | const { run, isSuccess } = useMutate(); | 231 | const { run, isSuccess } = useMutate(mutUpdateField); |
| 220 | | 232 | |
| 221 | return ( | 233 | return ( |
| 222 | <input | 234 | <input |
| ... | @@ -225,7 +237,7 @@ function Item({ id }: { id: string }) { | ... | @@ -225,7 +237,7 @@ function Item({ id }: { id: string }) { |
| 225 | mutUpdateField.run(id, e.target.value); | 237 | mutUpdateField.run(id, e.target.value); |
| 226 | }} | 238 | }} |
| 227 | /> | 239 | /> |
| 228 | {} | 240 | {isSuccess ? "Saved" : null} |
| 229 | ); | 241 | ); |
| 230 | } | 242 | } |
| 231 | ``` | 243 | ``` |
| ... | @@ -284,13 +296,13 @@ If the error is observed by the component, then React Mutation will know not to | ... | @@ -284,13 +296,13 @@ If the error is observed by the component, then React Mutation will know not to |
| 284 | invoke the global error handler. Same for success. | 296 | invoke the global error handler. Same for success. |
| 285 | | 297 | |
| 286 | ```ts | 298 | ```ts |
| 287 | const { errorMeseage, isSuccess, run: run1 } = useMutate(...); // local handling in the form | 299 | const { errorMessage, isSuccess, run: run1 } = useMutate(...); // local handling in the form |
| 288 | const { run: run2 } = useMutate(...); // global handling with alerts | 300 | const { run: run2 } = useMutate(...); // global handling with alerts |
| 289 | | 301 | |
| 290 | return ( | 302 | return ( |
| 291 | <> | 303 | <> |
| 292 | <button onClick={() => run1(...)}>local</button> | 304 | <button onClick={() => run1(...)}>local</button> |
| 293 | {isSuccess ? "you win!" : errorMeseage} | 305 | {isSuccess ? "you win!" : errorMessage} |
| 294 | | 306 | |
| 295 | <button onClick={() => run2(...)}>global</button> | 307 | <button onClick={() => run2(...)}>global</button> |
| 296 | <> | 308 | <> |