diff --git a/jsr.json b/jsr.json index a6b09d6d4ef9b2fbb0af41ede470cdac995fc5c7..2b3b61224f8b597f7aedee0ec7156a02cb895de0 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@clo/react-mutation", - "version": "1.0.0-rc.1", + "version": "1.0.0", "exports": { ".": "./src/mod.ts", "./tanstack-query.ts": "./src/tanstack-query.ts", diff --git a/readme.md b/readme.md index f5a9618023d53cd3e5dd5214e04b38c86886a2fe..c52b2d7adc8dbb2f8b06030b1c9a7364c28affd9 100644 --- a/readme.md +++ b/readme.md @@ -10,16 +10,16 @@ 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. -The primary gains React Mutation provides are: - -- **Automatic result handling**. If a `useMutate` hook does not observe +- **Automatic result handling**. If a `useMutate` call does not observe `isError`, unhandled errors will be propagated to a global handler, which can - display a UI toast. Otherwise, the component can display the error locally. + 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). - **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](#react-query-optimistic-helpers) shows this power in more detail. -- Extra treats such as debouncing (toggle button spam) and no-op filters (auto-save text inputs). +- **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). + +On our work repository, switching to React Mutation reduced the line count of our mutations in half (rough estimate). ## Setup @@ -39,9 +39,10 @@ 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. - // (btw, the correctly typed version of `get` is exported as `boundQueryClientGet(client)`) - get: (k: QueryKey) => client.getQueryData(k), + navigateAway: (urlThatIsBeingDeleted: string, redirect: string) => ..., }, // Optimistic helpers are a second type of context, only available within @@ -84,26 +85,35 @@ const mutDeleteItem = mutations.define({ }, // `optimistic` is provided a `helpers` object which implement automatic rollbacks. - optimistic({ client, get, helpers, args: [id], onSuccess }) { - // Remove the matching items, but restore and refetch them on failure. + 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); - // Remove this query from the client, but restore as stale and refetch it on failure. - helpers.removeQuery(queryItem); + // 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) => { - // in general case, you may want to apply a success update + // 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] }) { - const title = get(queryItem().queryKey)?.title ?? "Unknown Item"; - return `Delete '${get(queryItem(id))?.title ?? 'unknown'}'`; - }, + describe: ({ get, args: [id] }) => + `Delete '${get(queryItem(id))?.title ?? "Unknown Item"}'`, // Example: `Successfully {description}` - describeResult: ({ get, args: [id] }) => - `Deleted '${get(queryItem(id))?.title ?? 'Unknown Item'}'`, + 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. @@ -137,7 +147,7 @@ The `optimistic` function is given an object with the following APIs #### 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 is with this client +very easy to write optimistic updates. Our setup at work starts with this client configuration. ```ts @@ -157,6 +167,7 @@ export const mutations = new MutationClient({ get: boundQueryClientGet(client), }, getOptimisticHelpers: queryClientOptimisticHelpers(client), + // `showAlert` is our global toast function reportError(message) { showAlert(message, "error"); }, @@ -181,8 +192,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list - `arrayFilter` - preserve items by a `filter` function - `arrayUpdate` - update items by a `filter` + `update` function - `arrayInsertIndex` - insert an item at an index -- Queries that are complex objects. Each function takes a type-safe json path to - evaluate, but this system currently has type bugs and is being improved. +- Queries that are complex objects. Each function takes a type-safe object path to + evaluate. - `objSet` - set a property - `objSetMany` - set many properties at once - `objIncrement` - increment a number @@ -198,7 +209,8 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list ## Debouncing By default, a mutation will block the UI (by setting isPending). If you add -`debounceMs`, the mutation will no longer set isPending. Multiple mutations +`debounceMs`, the mutation will no longer set isPending. Consecutive mutations +will override the earlier calls by rolling back the optimistic state. ```tsx const mutUpdateField = mutations.define({ @@ -208,15 +220,15 @@ const mutUpdateField = mutations.define({ }, // (...describe functions...) - // debounce for 0.5 seconds, grouping items on their `id` - debounceMs: 500, - key: ({ args: [id] }) => id, + 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(); + const { run, isSuccess } = useMutate(mutUpdateField); return ( - {} + {isSuccess ? "Saved" : null} ); } ``` @@ -284,13 +296,13 @@ If the error is observed by the component, then React Mutation will know not to invoke the global error handler. Same for success. ```ts -const { errorMeseage, isSuccess, run: run1 } = useMutate(...); // local handling in the form +const { errorMessage, isSuccess, run: run1 } = useMutate(...); // local handling in the form const { run: run2 } = useMutate(...); // global handling with alerts return ( <> - {isSuccess ? "you win!" : errorMeseage} + {isSuccess ? "you win!" : errorMessage} <>