| ... | ... | @@ -10,16 +10,16 @@ their mutation story falls apart, is confusing, and misses a few obvious |
| 10 | 10 | features. Additionally, coworkers using AI agents continue to propagate bad |
| 11 | 11 | patterns and verbose code that is hard to review. |
| 12 | 12 | |
| 13 | | The primary gains React Mutation provides are: |
| 14 | | |
| 15 | | - **Automatic result handling**. If a `useMutate` hook does not observe |
| 13 | - **Automatic result handling**. If a `useMutate` call does not observe |
| 16 | 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 | 16 | - **Optimistic helpers with built-in rollbacks** make it super easy to alter the |
| 19 | 17 | UI without worrying about bugged error states. The |
| 20 | 18 | [built in helpers for React Query](#react-query-optimistic-helpers) shows |
| 21 | 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 | 24 | ## Setup |
| 25 | 25 | |
| ... | ... | @@ -39,9 +39,10 @@ export const mutations = new MutationClient({ |
| 39 | 39 | // All properties in `context` are available within every function. |
| 40 | 40 | context: { |
| 41 | 41 | client: queryClient, |
| 42 | get: boundQueryClientGet(client), |
| 43 | |
| 42 | 44 | // Can add any easy helpers for your codebase. |
| 43 | | // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`) |
| 44 | | get: (k: QueryKey) => client.getQueryData(k), |
| 45 | navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ..., |
| 45 | 46 | }, |
| 46 | 47 | |
| 47 | 48 | // Optimistic helpers are a second type of context, only available within |
| ... | ... | @@ -84,26 +85,35 @@ const mutDeleteItem = mutations.define({ |
| 84 | 85 | }, |
| 85 | 86 | |
| 86 | 87 | // `optimistic` is provided a `helpers` object which implement automatic rollbacks. |
| 87 | | optimistic({ client, get, helpers, args: [id], onSuccess }) { |
| 88 | | // Remove the matching items, but restore and refetch them on failure. |
| 88 | optimistic({ client, get, helpers, args: [id], onSuccess, onRestore, onRefetch }) { |
| 89 | // Remove the items matching the filter, but restore and refetch them on failure. |
| 89 | 90 | helpers.arrayRemove(queryItemList, (item) => item === id); |
| 90 | | // Remove this query from the client, but restore as stale and refetch it on failure. |
| 91 | | helpers.removeQuery(queryItem); |
| 91 | // Set a property on an object, also restores and refetches. The `obj` |
| 92 | // helpers implement a type-safe object path system for nested fields. |
| 93 | helpers.objSet(queryItem(id), ["deleted"], true); |
| 92 | 94 | |
| 93 | 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 | 111 | // These strings are shown in error/success messages, called *after* optimistic state is applied. |
| 99 | 112 | // Example: `Could not {description}` |
| 100 | | describe({ get, args: [id] }) { |
| 101 | | const title = get(queryItem().queryKey)?.title ?? "Unknown Item"; |
| 102 | | return `Delete '${get(queryItem(id))?.title ?? 'unknown'}'`; |
| 103 | | }, |
| 113 | describe: ({ get, args: [id] }) => |
| 114 | `Delete '${get(queryItem(id))?.title ?? "Unknown Item"}'`, |
| 104 | 115 | // Example: `Successfully {description}` |
| 105 | | describeResult: ({ get, args: [id] }) => |
| 106 | | `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`, |
| 116 | describeResult: ({ get, args: [id] }) => "Deleted Item", |
| 107 | 117 | |
| 108 | 118 | // Since the optimistic handler is perfect, there is no need to refetch any |
| 109 | 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 | 147 | #### React Query Optimistic Helpers |
| 138 | 148 | |
| 139 | 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 | 151 | configuration. |
| 142 | 152 | |
| 143 | 153 | ```ts |
| ... | ... | @@ -157,6 +167,7 @@ export const mutations = new MutationClient({ |
| 157 | 167 | get: boundQueryClientGet(client), |
| 158 | 168 | }, |
| 159 | 169 | getOptimisticHelpers: queryClientOptimisticHelpers(client), |
| 170 | // `showAlert` is our global toast function |
| 160 | 171 | reportError(message) { |
| 161 | 172 | showAlert(message, "error"); |
| 162 | 173 | }, |
| ... | ... | @@ -181,8 +192,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list |
| 181 | 192 | - `arrayFilter` - preserve items by a `filter` function |
| 182 | 193 | - `arrayUpdate` - update items by a `filter` + `update` function |
| 183 | 194 | - `arrayInsertIndex` - insert an item at an index |
| 184 | | - Queries that are complex objects. Each function takes a type-safe json path to |
| 185 | | evaluate, but this system currently has type bugs and is being improved. |
| 195 | - Queries that are complex objects. Each function takes a type-safe object path to |
| 196 | evaluate. |
| 186 | 197 | - `objSet` - set a property |
| 187 | 198 | - `objSetMany` - set many properties at once |
| 188 | 199 | - `objIncrement` - increment a number |
| ... | ... | @@ -198,7 +209,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list |
| 198 | 209 | ## Debouncing |
| 199 | 210 | |
| 200 | 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 | 215 | ```tsx |
| 204 | 216 | const mutUpdateField = mutations.define({ |
| ... | ... | @@ -208,15 +220,15 @@ const mutUpdateField = mutations.define({ |
| 208 | 220 | }, |
| 209 | 221 | // (...describe functions...) |
| 210 | 222 | |
| 211 | | // debounce for 0.5 seconds, grouping items on their `id` |
| 212 | | debounceMs: 500, |
| 213 | | key: ({ args: [id] }) => id, |
| 223 | debounceMs: 500, // wait 0.5 seconds before mutating |
| 224 | key: ({ args: [id] }) => id, // place same `ids` into the same timer group |
| 225 | // debounceImmediate: true, // can also support leading edge, good for buttons |
| 214 | 226 | }); |
| 215 | 227 | |
| 216 | 228 | // React example - Auto-saving text field |
| 217 | 229 | function Item({ id }: { id: string }) { |
| 218 | 230 | const { data: item } = useSuspenseQuery(queryItem(id)); |
| 219 | | const { run, isSuccess } = useMutate(); |
| 231 | const { run, isSuccess } = useMutate(mutUpdateField); |
| 220 | 232 | |
| 221 | 233 | return ( |
| 222 | 234 | <input |
| ... | ... | @@ -225,7 +237,7 @@ function Item({ id }: { id: string }) { |
| 225 | 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 | 296 | invoke the global error handler. Same for success. |
| 285 | 297 | |
| 286 | 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 | 300 | const { run: run2 } = useMutate(...); // global handling with alerts |
| 289 | 301 | |
| 290 | 302 | return ( |
| 291 | 303 | <> |
| 292 | 304 | <button onClick={() => run1(...)}>local</button> |
| 293 | | {isSuccess ? "you win!" : errorMeseage} |
| 305 | {isSuccess ? "you win!" : errorMessage} |
| 294 | 306 | |
| 295 | 307 | <button onClick={() => run2(...)}>global</button> |
| 296 | 308 | <> |