React Admin v3 Advanced Recipes - Adding Related Records Inline With Custom Forms

Gildas Garcia
Gildas GarciaApril 27, 2020
#react-admin#react#tutorial#popular

For React-Admin v4 users

React-Admin v4 introduced the <CreateInDialogButton>, a button component allowing to add a related record, that is easy to integrate in a custom form.

Sine we released React-Admin v3 several months ago, many people have been asking for a revision of a popular tutorial we wrote for v2: React Admin Advanced Recipes - Adding Related Records Inline With Custom Forms.

In this article, I'll show not only how to do the same in v3, but also how to migrate the v2 code. But first, let's review what we achieved in the previous version.

If you don't really care about the migration process, you can find the final code in the following codesandbox: https://codesandbox.io/s/react-admin-v3-advanced-recipes-quick-createpreview-voyci.

Screencast showing the end result

What We Had

The goal of the previous tutorial was to provide users with a way to quickly create a resource related to the one they were creating or editing, without leaving the page.

In the example, users creating a new comment were able to either select an existing post, or to quickly create a new one with its most basic fields inside a modal. When editing an existing comment, users could either quickly create a new post or see more field of the current one in a side panel.

Challenges Of A React-Admin V3 Migration

One of the most impacting changes when migrating to react-admin v3 is the move from redux-form to final-form. Although the two libraries share (mostly) the same API, there is a key difference: in react-final-form, the form state is no longer available outside of the form hierarchy.

However, react-admin v3 ships many new useful features that will make the code more concise and easier to read. Besides, React now provides hooks, and I'm going to convert some class based components to functional ones.

Let's get to it!

Hello Final Form

The first component to update is PostQuickCreateButton.

It is responsible for showing a Create button, which will display a dialog containing a post creation form.

First, I need to remove all the redux code which is unecessary now. Goodbye mapStateToProps, mapDispatchToProps and connect!

-const mapStateToProps = state => ({
-  isSubmitting: isSubmitting("post-quick-create")(state),
-});
-
-const mapDispatchToProps = {
-  change,
-  fetchEnd,
-  fetchStart,
-  showNotification,
-  submit,
-};
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(PostQuickCreateButton);
+export default PostQuickCreateButton;

Then, I can remove the form prop on the SimpleForm component because form states are not stored in redux anymore, and we don't need unique names for the forms. Additionaly, the onSubmit prop can be swapped for save, which react-admin v3 allows us to override.

<SimpleForm
-   // We override the redux-form name to avoid collision with the react-admin main form
-   form="post-quick-create"
    resource="posts"
-   // We override the redux-form onSubmit prop to handle the submission ourselves
-   onSubmit={this.handleSubmit}
+   save={handleSubmit}
    // We want no toolbar at all as we have our modal actions
    toolbar={null}
>

There's a catch though: I can't submit the form using buttons which are outside of it anymore, and I don't want the default SimpleForm toolbar as the buttons would not be inside the DialogActions.

I'll stop you right there, no, I won't use refs and trigger a submit event. That just doesn't feel right.

Using <FormWithRedirect>

What I need is a form that does not force me to adopt its layout. Fortunately, react-admin v3 provides one - not yet documented, yet super useful - called FormWithRedirect:

<FormWithRedirect
    resource="posts"
    save={handleSubmit}
    render={({
        handleSubmitWithRedirect,
        pristine,
        saving
    }) => (
        <>
            <DialogContent>
                <TextInput
                    source="title"
                    validate={required()}
                    fullWidth
                />
                <TextInput
                    source="teaser"
                    validate={required()}
                    fullWidth
                />
            </DialogContent>
            <DialogActions>
                <Button
                    label="ra.action.cancel"
                    onClick={handleCloseClick}
                    disabled={loading}
                >
                    <IconCancel />
                </Button>
                <SaveButton
                    handleSubmitWithRedirect={
                        handleSubmitWithRedirect
                    }
                    pristine={pristine}
                    saving={saving}
                    disabled={loading}
                />
            </DialogActions>
        </>
    )}
/>

Now, I'll review the handleSubmit handler. Indeed, as I removed the redux connected props, I no longer have the change, fetchStart, fetchEnd and showNotification actions dispatchers.

For the change action that was used to trigger a field change on the comment form, I can use react-final-form's useForm hook, which will give me access to the API of the closest form.

For the showNotification actions, I can use the new react-admin hook useNotify.

I can also get rid of the dataProvider import and direct call, as I'll use another react-admin v3 new hook, useCreate. The fetchStart and fetchEnd actions are not needed anymore because the hook will take care of setting the global loading state.

function PostQuickCreateButton() {
    // ...

    const [create, { loading }] = useCreate('posts');
    const notify = useNotify();
    const form = useForm();

    // ...

    const handleSubmit = async values => {
        create(
            { payload: { data: values } },
            {
                onSuccess: ({ data }) => {
                    setShowDialog(false);
                    // Update the comment form to target the newly created post
                    // Updating the ReferenceInput value will force it to reload the available posts
                    form.change('post_id', data.id);
                },
                onFailure: ({ error }) => {
                    notify(error.message, 'error');
                }
            }
        );
    };

    // ...
}

That's it! Oh wait, I have the same issue as in the first version of this article. When I create a new post, it is indeed selected but if I select another post, the newly created one disappears.

That's because the <ReferenceInput> component uses a specific subset of the redux store to track the ids of the posts to display. It then fetches the data from the global list of data in the store using those ids. This list of ids to display is not updated when we create a new post. Manually setting the value to the newly created post works because even though it wasn't in the initial list, the id is now in the global list of data. When we select another post, <ReferenceInput> rerenders and only displays the posts retrieved using the list of ids it knows about.

To fix it, I need to force the <ReferenceInput> to refetch its data. The easiest way to do that is to change its key. I'll add an event handler callback to the PostQuickCreateButton:

function PostQuickCreateButton({ onChange }) {
    // ...

    const [create, { loading }] = useCreate('posts');
    const notify = useNotify();
    const form = useForm();

    // ...

    const handleSubmit = async values => {
        create(
            { payload: { data: values } },
            {
                onSuccess: ({ data }) => {
                    setShowDialog(false);
                    // Update the comment form to target the newly created post
                    // Updating the ReferenceInput value will force it to reload the available posts
                    form.change('post_id', data.id);
                    // Notify the parent that the value has changed
                    onChange();
                },
                onFailure: ({ error }) => {
                    notify(error.message, 'error');
                }
            }
        );
    };

    // ...
}

Here's a link to the full code for the PostQuickCreateButton.

Now, I can use this event handler in the PostReferenceInput component:

const PostReferenceInput = props => {
    const { values } = useFormState({ subscription: spySubscription });
+   const [version, setVersion] = useState(0);
+   const handleChange = useCallback(() => setVersion(version + 1), [version]);

    return (
        <div className={classes.root}>
            <ReferenceInput key={version} {...props}>
                <SelectInput optionText="title" />
            </ReferenceInput>

-           <PostQuickCreateButton />
+           <PostQuickCreateButton onChange={handleChange} />
            {!!values.post_id && <PostQuickPreviewButton id={values.post_id} />}
        </div>
    );
};

Updating the <PostReferenceInput>

The next component I'm going to update is PostReferenceInput because it uses the Field component from redux-form.

I could just update the import from import { Field } from 'redux-form'; to import { Field } from 'react-final-form';. It would actually work. However, Field is used to register a new field in final-form, and there is a better way to get the values from the closest form.

I introduced the useForm hook already but I won't use it here as it will not trigger a render when the form values change. Instead I'll use the useFormState hook, which is provided for this purpose.

import { useFormState } from 'react-final-form';

// Only trigger a render when the form values change
const spySubscription = { values: true };

const PostReferenceInput = props => {
    const classes = useStyles();
    const { values } = useFormState({ subscription: spySubscription });

    return (
        <div className={classes.root}>
            <ReferenceInput {...props}>
                <SelectInput optionText="title" />
            </ReferenceInput>

            <PostQuickCreateButton />
            {!!values.post_id && <PostQuickPreviewButton id={values.post_id} />}
        </div>
    );
};

Updating The Show View

This part is actually not required as everything is working. However, I'll take this opportunity to show another new hook of react-admin v3.

In the previous version of the PostQuickPreviewButton component, I retrieved the selected post data directly from the redux store:

const mapStateToProps = (state, props) => ({
  // Get the record by its id from the react-admin state.
  record: state.admin.resources[props.resource]
    ? state.admin.resources[props.resource].data[props.id]
    : null,
  version: state.admin.ui.viewVersion,
});

In react-admin v3, I can now leverage the useGetOne() hook instead, which means I can remove all the redux related code:

const PostQuickPreviewButton = ({ id }) => {
    const [showPanel, setShowPanel] = useState(false);
    const classes = useStyles();
    const { data } = useGetOne('posts', id);

    const handleClick = () => {
        setShowPanel(true);
    };

    const handleCloseClick = () => {
        setShowPanel(false);
    };

    return (
        <>
            <Button onClick={handleClick} label="ra.action.show">
                <IconImageEye />
            </Button>
            <Drawer anchor="right" open={showPanel} onClose={handleCloseClick}>
                <div>
                    <Button label="Close" onClick={handleCloseClick}>
                        <IconKeyboardArrowRight />
                    </Button>
                </div>
                <SimpleShowLayout
                    record={data}
                    basePath="/posts"
                    resource="posts"
                >
                    <TextField source="id" />
                    <TextField source="title" className={classes.field} />
                    <TextField source="teaser" className={classes.field} />
                </SimpleShowLayout>
            </Drawer>
        </>
    );
};

Conclusion

As you may have noticed, redux is now an implementation detail in react-admin v3. Most of the time you won't need to write any redux code, avoiding a lot of boilerplate.

Besides, thanks to React hooks and the new react-admin hooks, the code is simpler and easier to follow.

Did you like this article? Share it!