Skip to content

Commit 89d445b

Browse files
authored
Merge pull request #10530 from marmelab/create-mutation-mode
Add support for mutationMode in useCreate
2 parents 93c4a04 + e2b14a9 commit 89d445b

15 files changed

+1915
-124
lines changed

docs/Create.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ You can customize the `<Create>` component using the following props:
6060
* `className`: passed to the root component
6161
* [`component`](#component): override the root component
6262
* [`disableAuthentication`](#disableauthentication): disable the authentication check
63+
* [`mutationMode`](#mutationmode): switch to optimistic or undoable mutations (pessimistic by default)
6364
* [`mutationOptions`](#mutationoptions): options for the `dataProvider.create()` call
6465
* [`record`](#record): initialize the form with a record
6566
* [`redirect`](#redirect): change the redirect location after successful creation
@@ -154,6 +155,34 @@ const PostCreate = () => (
154155
);
155156
```
156157

158+
## `mutationMode`
159+
160+
The `<Create>` view exposes a Save button, which perform a "mutation" (i.e. it creates the data). React-admin offers three modes for mutations. The mode determines when the side effects (redirection, notifications, etc.) are executed:
161+
162+
- `pessimistic` (default): The mutation is passed to the dataProvider first. When the dataProvider returns successfully, the mutation is applied locally, and the side effects are executed.
163+
- `optimistic`: The mutation is applied locally and the side effects are executed immediately. Then the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown.
164+
- `undoable`: The mutation is applied locally and the side effects are executed immediately. Then a notification is shown with an undo button. If the user clicks on undo, the mutation is never sent to the dataProvider, and the page is refreshed. Otherwise, after a 5 seconds delay, the mutation is passed to the dataProvider. If the dataProvider returns successfully, nothing happens (as the mutation was already applied locally). If the dataProvider returns in error, the page is refreshed and an error notification is shown.
165+
166+
By default, pages using `<Create>` use the `pessimistic` mutation mode as the new record identifier is often generated on the backend. However, should you decide to generate this identifier client side, you can change the `mutationMode` to either `optimistic` or `undoable`:
167+
168+
```jsx
169+
const PostCreate = () => (
170+
<Create mutationMode="optimistic" transform={data => ({ id: generateId(), ...data })}>
171+
// ...
172+
</Create>
173+
);
174+
```
175+
176+
And to make the record creation undoable:
177+
178+
```jsx
179+
const PostCreate = () => (
180+
<Create mutationMode="undoable" transform={data => ({ id: generateId(), ...data })}>
181+
// ...
182+
</Create>
183+
);
184+
```
185+
157186
## `mutationOptions`
158187

159188
You can customize the options you pass to react-query's `useMutation` hook, e.g. to pass [a custom `meta`](./Actions.md#meta-parameter) to the `dataProvider.create()` call.

docs/CreateBase.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ You can customize the `<CreateBase>` component using the following props, docume
4646

4747
* `children`: the components that renders the form
4848
* [`disableAuthentication`](./Create.md#disableauthentication): disable the authentication check
49+
* [`mutationMode`](./Create.md#mutationmode): Switch to optimistic or undoable mutations (pessimistic by default)
4950
* [`mutationOptions`](./Create.md#mutationoptions): options for the `dataProvider.create()` call
5051
* [`record`](./Create.md#record): initialize the form with a record
5152
* [`redirect`](./Create.md#redirect): change the redirect location after successful creation

docs/useCreate.md

Lines changed: 264 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ This hook allows to call `dataProvider.create()` when the callback is executed.
99

1010
## Syntax
1111

12-
```jsx
12+
```tsx
1313
const [create, { data, isPending, error }] = useCreate(
1414
resource,
1515
{ data, meta },
@@ -19,7 +19,7 @@ const [create, { data, isPending, error }] = useCreate(
1919

2020
The `create()` method can be called with the same parameters as the hook:
2121

22-
```jsx
22+
```tsx
2323
create(
2424
resource,
2525
{ data },
@@ -31,7 +31,7 @@ So, should you pass the parameters when calling the hook, or when executing the
3131

3232
## Usage
3333

34-
```jsx
34+
```tsx
3535
// set params when calling the hook
3636
import { useCreate, useRecordContext } from 'react-admin';
3737

@@ -61,6 +61,267 @@ const LikeButton = () => {
6161
};
6262
```
6363

64+
## Params
65+
66+
The second argument of the `useCreate` hook is an object with the following properties:
67+
68+
- `data`: the new data for the record,
69+
- `meta`: an object to pass additional information to the dataProvider (optional).
70+
71+
```tsx
72+
const LikeButton = () => {
73+
const record = useRecordContext();
74+
const like = { postId: record.id };
75+
const [create, { isPending, error }] = useCreate('likes', { data: like });
76+
const handleClick = () => {
77+
create()
78+
}
79+
if (error) { return <p>ERROR</p>; }
80+
return <button disabled={isPending} onClick={handleClick}>Like</button>;
81+
};```
82+
83+
`data` the record to create.
84+
85+
`meta` is helpful for passing additional information to the dataProvider. For instance, you can pass the current user to let a server-side audit system know who made the creation.
86+
87+
## Options
88+
89+
`useCreate`'s third parameter is an `options` object with the following properties:
90+
91+
- `mutationMode`,
92+
- `onError`,
93+
- `onSettled`,
94+
- `onSuccess`,
95+
- `returnPromise`.
96+
97+
```tsx
98+
const notify = useNotify();
99+
const redirect = useRedirect();
100+
101+
const [create, { isPending, error }] = useCreate(
102+
'likes',
103+
{ data: { id: uuid.v4(), postId: record.id } },
104+
{
105+
mutationMode: 'optimistic',
106+
onSuccess: () => {
107+
notify('Like created');
108+
redirect('/reviews');
109+
},
110+
onError: (error) => {
111+
notify(`Like creation error: ${error.message}`, { type: 'error' });
112+
},
113+
});
114+
115+
```
116+
117+
Additional options are passed to [React Query](https://tanstack.com/query/v5/)'s [`useMutation`](https://tanstack.com/query/v5/docs/react/reference/useMutation) hook. This includes:
118+
119+
- `gcTime`,
120+
- `networkMode`,
121+
- `onMutate`,
122+
- `retry`,
123+
- `retryDelay`,
124+
- `mutationKey`,
125+
- `throwOnError`.
126+
127+
Check [the useMutation documentation](https://tanstack.com/query/v5/docs/react/reference/useMutation) for a detailed description of all options.
128+
129+
**Tip**: In react-admin components that use `useCreate`, you can override the mutation options using the `mutationOptions` prop. This is very common when using mutation hooks like `useCreate`, e.g., to display a notification or redirect to another page.
130+
131+
For instance, here is a button using `<Create mutationOptions>` to notify the user of success using the bottom notification banner:
132+
133+
{% raw %}
134+
```tsx
135+
import * as React from 'react';
136+
import { useNotify, useRedirect, Create, SimpleForm } from 'react-admin';
137+
138+
const PostCreate = () => {
139+
const notify = useNotify();
140+
const redirect = useRedirect();
141+
142+
const onSuccess = (data) => {
143+
notify(`Changes saved`);
144+
redirect(`/posts/${data.id}`);
145+
};
146+
147+
return (
148+
<Create mutationOptions={{ onSuccess }}>
149+
<SimpleForm>
150+
...
151+
</SimpleForm>
152+
</Create>
153+
);
154+
}
155+
```
156+
{% endraw %}
157+
158+
## Return Value
159+
160+
The `useCreate` hook returns an array with two values:
161+
162+
- the `create` callback, and
163+
- a mutation state object with the following properties:
164+
- `data`,
165+
- `error`,
166+
- `isError`,
167+
- `isIdle`,
168+
- `isPending`,
169+
- `isPaused`,
170+
- `isSuccess`,
171+
- `failureCount`,
172+
- `failureReason`,
173+
- `mutate`,
174+
- `mutateAsync`,
175+
- `reset`,
176+
- `status`,
177+
- `submittedAt`,
178+
- `variables`.
179+
180+
The `create` callback can be called with a `resource` and a `param` argument, or, if these arguments were defined when calling `useCreate`, with no argument at all:
181+
182+
```jsx
183+
// Option 1: define the resource and params when calling the callback
184+
const [create, { isPending }] = useCreate();
185+
const handleClick = () => {
186+
create(resource, params, options);
187+
};
188+
189+
// Option 2: define the resource and params when calling the hook
190+
const [create, { isPending }] = useCreate(resource, params, options);
191+
const handleClick = () => {
192+
create();
193+
};
194+
```
195+
196+
For a detailed description of the mutation state, check React-query's [`useMutation` documentation](https://tanstack.com/query/v5/docs/react/reference/useMutation).
197+
198+
Since `useCreate` is mainly used in event handlers, success and error side effects are usually handled in the `onSuccess` and `onError` callbacks. In most cases, the mutation state is just used to disable the save button while the mutation is pending.
199+
200+
## `mutationMode`
201+
202+
The `mutationMode` option lets you switch between three rendering modes, which change how the success side effects are triggered:
203+
204+
- `pessimistic` (the default)
205+
- `optimistic`, and
206+
- `undoable`
207+
208+
**Note**: For `optimistic` and `undoable` modes, the record `id` must be generated client side. Those two modes are useful when building local first applications.
209+
210+
Here is an example of using the `optimistic` mode:
211+
212+
```jsx
213+
// In optimistic mode, ids must be generated client side
214+
const id = uuid.v4();
215+
const [create, { data, isPending, error }] = useCreate(
216+
'comments',
217+
{ data: { id, message: 'Lorem ipsum' } },
218+
{
219+
mutationMode: 'optimistic',
220+
onSuccess: () => { /* ... */},
221+
onError: () => { /* ... */},
222+
}
223+
);
224+
```
225+
226+
In `pessimistic` mode, the `onSuccess` side effect executes *after* the dataProvider responds.
227+
228+
In `optimistic` mode, the `onSuccess` side effect executes just before the `dataProvider.create()` is called, without waiting for the response.
229+
230+
In `undoable` mode, the `onSuccess` side effect fires immediately. The actual call to the dataProvider is delayed until the create notification hides. If the user clicks the undo button, the `dataProvider.create()` call is never made.
231+
232+
See [Optimistic Rendering and Undo](./Actions.md#optimistic-rendering-and-undo) for more details.
233+
234+
**Tip**: If you need a side effect to be triggered after the dataProvider response in `optimistic` and `undoable` modes, use the `onSettled` callback.
235+
236+
## `onError`
237+
238+
The `onError` callback is called when the mutation fails. It's the perfect place to display an error message to the user.
239+
240+
```jsx
241+
const notify = useNotify();
242+
const [create, { data, isPending, error }] = useCreate(
243+
'comments',
244+
{ id: record.id, data: { isApproved: true } },
245+
{
246+
onError: () => {
247+
notify('Error: comment not approved', { type: 'error' });
248+
},
249+
}
250+
);
251+
```
252+
253+
**Note**: If you use the `retry` option, the `onError` callback is called only after the last retry has failed.
254+
255+
## `onSettled`
256+
257+
The `onSettled` callback is called at the end of the mutation, whether it succeeds or fails. It will receive either the `data` or the `error`.
258+
259+
```jsx
260+
const notify = useNotify();
261+
const [create, { data, isPending, error }] = useCreate(
262+
'comments',
263+
{ id: record.id, data: { isApproved: true } },
264+
{
265+
onSettled: (data, error) => {
266+
// ...
267+
},
268+
}
269+
);
270+
```
271+
272+
**Tip**: The `onSettled` callback is perfect for calling a success side effect after the dataProvider response in `optimistic` and `undoable` modes.
273+
274+
## `onSuccess`
275+
276+
The `onSuccess` callback is called when the mutation succeeds. It's the perfect place to display a notification or to redirect the user to another page.
277+
278+
```jsx
279+
const notify = useNotify();
280+
const redirect = useRedirect();
281+
const [create, { data, isPending, error }] = useCreate(
282+
'comments',
283+
{ id: record.id, data: { isApproved: true } },
284+
{
285+
onSuccess: () => {
286+
notify('Comment approved');
287+
redirect('/comments');
288+
},
289+
}
290+
);
291+
```
292+
293+
In `pessimistic` mutation mode, `onSuccess` executes *after* the `dataProvider.create()` responds. React-admin passes the result of the `dataProvider.create()` call as the first argument to the `onSuccess` callback.
294+
295+
In `optimistic` mutation mode, `onSuccess` executes *before* the `dataProvider.create()` is called, without waiting for the response. The callback receives no argument.
296+
297+
In `undoable` mutation mode, `onSuccess` executes *before* the `dataProvider.create()` is called. The actual call to the dataProvider is delayed until the create notification hides. If the user clicks the undo button, the `dataProvider.create()` call is never made. The callback receives no argument.
298+
299+
## `returnPromise`
300+
301+
By default, the `create` callback that `useCreate` returns is synchronous and returns nothing. To execute a side effect after the mutation has succeeded, you can use the `onSuccess` callback.
302+
303+
If this is not enough, you can use the `returnPromise` option so that the `create` callback returns a promise that resolves when the mutation has succeeded and rejects when the mutation has failed.
304+
305+
This can be useful if the server changes the record, and you need the newly created data to create/update another record.
306+
307+
```jsx
308+
const [createPost] = useCreate(
309+
'posts',
310+
{ id: record.id, data: { isPublished: true } },
311+
{ returnPromise: true }
312+
);
313+
const [createAuditLog] = useCreate('auditLogs');
314+
315+
const createPost = async () => {
316+
try {
317+
const post = await createPost();
318+
createAuditLog('auditLogs', { data: { action: 'create', recordId: post.id, date: post.createdAt } });
319+
} catch (error) {
320+
// handle error
321+
}
322+
};
323+
```
324+
64325
## TypeScript
65326

66327
The `useCreate` hook accepts a generic parameter for the record type and another for the error type:

docs/useCreateController.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const BookCreate = () => {
5050
`useCreateController` accepts an object with the following keys, all optional:
5151

5252
* [`disableAuthentication`](./Create.md#disableauthentication): Disable the authentication check
53+
* [`mutationMode`](./Create.md#mutationmode): Switch to optimistic or undoable mutations (pessimistic by default)
5354
* [`mutationOptions`](./Create.md#mutationoptions): Options for the `dataProvider.create()` call
5455
* [`record`](./Create.md#record): Use the provided record as base instead of fetching it
5556
* [`redirect`](./Create.md#redirect): Change the redirect location after successful creation
@@ -65,6 +66,7 @@ These fields are documented in [the `<Create>` component](./Create.md) documenta
6566
```jsx
6667
const {
6768
defaultTitle, // Translated title based on the resource, e.g. 'Create New Post'
69+
mutationMode, // Mutation mode argument passed as parameter, or 'pessimistic' if not defined
6870
record, // Default values of the creation form
6971
redirect, // Default redirect route. Defaults to 'list'
7072
resource, // Resource name, deduced from the location. e.g. 'posts'

packages/ra-core/src/controller/create/CreateBase.spec.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe('CreateBase', () => {
5353
test: 'test',
5454
},
5555
{ data: { test: 'test' }, resource: 'posts' },
56-
undefined
56+
{ snapshot: [] }
5757
);
5858
});
5959
});
@@ -85,7 +85,7 @@ describe('CreateBase', () => {
8585
test: 'test',
8686
},
8787
{ data: { test: 'test' }, resource: 'posts' },
88-
undefined
88+
{ snapshot: [] }
8989
);
9090
});
9191
expect(onSuccess).not.toHaveBeenCalled();
@@ -112,7 +112,7 @@ describe('CreateBase', () => {
112112
expect(onError).toHaveBeenCalledWith(
113113
{ message: 'test' },
114114
{ data: { test: 'test' }, resource: 'posts' },
115-
undefined
115+
{ snapshot: [] }
116116
);
117117
});
118118
});
@@ -139,7 +139,7 @@ describe('CreateBase', () => {
139139
expect(onErrorOverride).toHaveBeenCalledWith(
140140
{ message: 'test' },
141141
{ data: { test: 'test' }, resource: 'posts' },
142-
undefined
142+
{ snapshot: [] }
143143
);
144144
});
145145
expect(onError).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)