React-admin V4: Build Your Own Framework

François Zaninotto
François ZaninottoApril 11, 2022
#react#react-admin#tutorial

React-admin finds its roots in the process of removing boilerplate code and letting developers focus on business logic. With react-admin v4, this becomes even more apparent. Let's see how an edition page built with react-admin compares to a hand-made page.

With and without react-admin

An Edition View Built By Hand

Edition views are very common in single-page apps. The most usual way to allow a user to update a record is to fetch the record from an API based on the URL parameters, initialize a form with the record, update the inputs as the user changes the values, and call the API to update the record with the new values upon submission.

For instance, here is a book edition view with a form displaying 3 inputs (two text inputs and one select input), and redirecting to the book list view upon successful submission. This view renders under the /books/:id route.

Book edition view

Let's forget about react-admin for a while. Here is how I'd write this component in pure React, leveraging react-router to handle URL parameters and navigation, and react-hook-form to bind form inputs with a record object:

import * as React from 'react';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { Card, TextField, Button, Stack, MenuItem } from '@mui/material';

export const BookEdit = () => {
    const { id } = useParams();
    const { handleSubmit, reset, control } = useForm();

    // load book record on mount
    const [isLoading, setIsLoading] = useState(true);
    useEffect(() => {
        fetch(`/api/books/${id}`)
            .then(res => res.json())
            .then(({ data }) => {
                // initialize form with the result
                reset(data)
                setIsLoading(false);
            });
    }, [id]);

    // update book record on submit
    const [isSubmitting, setIsSubmitting] = useState(false);
    const navigate = useNavigate();
    const onSubmit = data => {
        setIsSubmitting(true);
        const options = {
            body: JSON.stringify({ data })
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
        };
        fetch(`/api/books/${id}`, options)
            .then(() => {
                setIsSubmitting(false);
                navigate('/books');
            });
    };

    if (isLoading) return null;

    return (
        <div>
            <h1>Book Edition</h1>
            <Card>
                <form onSubmit={handleSubmit(onSubmit)}>
                    <Stack spacing={2}>
                        <Controller
                            name="title"
                            render={({ field }) => (
                                <TextField label="Title" {...field} />
                            )}
                            control={control}
                        />
                        <Controller
                            name="author"
                            render={({ field }) => (
                                <TextField label="Author" {...field} />
                            )}
                            control={control}
                        />
                        <Controller
                            name="availability"
                            render={({ field }) => (
                                <TextField
                                    select
                                    label="Availability"
                                    {...field}
                                >
                                    <MenuItem value="in_stock">
                                        In stock
                                    </MenuItem>
                                    <MenuItem value="out_of_stock">
                                        Out of stock
                                    </MenuItem>
                                    <MenuItem value="out_of_print">
                                        Out of print
                                    </MenuItem>
                                </TextField>
                            )}
                            control={control}
                        />
                        <Button type="submit" disabled={isSubmitting}>
                            Save
                        </Button>
                    </Stack>
                </form>
            </Card>
        </div>
    );
};

That's a lot of code for such a simple page! Yet, it doesn't manage default values for inputs, validation, or dependent inputs. It doesn't even handle authentication or error cases for API calls.

It's a super common component. In fact, many of its features could be extracted for reuse on other pages.

How would you improve the code and the developer experience?

Interlude: Developer Experience And Complexity

Out of the Tar Pit, a famous computer science paper published in 2006, really opened my eyes regarding software development and developer experience.

It identifies complexity as the single major difficulty in the successful development of large-scale software systems. Other properties make building software hard (such as the ones identified in another well-known paper, No Silver Bullet, in 1986: conformity, changeability, and invisibility), but they are particular forms of complexity, or caused by some complexity in a system.

The paper sees state, control (i.e. the order in which things happen), and code volume as the main causes of complexity. It also suggests techniques to reduce them:

  • Object-orientation
  • Functional programming
  • Declarative programming

The paper is very readable even if you don't have a CS degree. I encourage you to take an hour to discover it in extenso.

I'll use the techniques encouraged by Out Of The Tar Pit to reduce the complexity of the initial code example.

Extracting API Calls To Custom Hooks

Handling API calls is such a common task in React that there are dozens of libraries that handle it for you. We can leverage for example react-query to handle API calls.

But react-query's useQuery and useMutation hooks are low-level primitives. We can imagine higher-level hooks like useGetOne and useUpdate to manage common CRUD operations.

I won't show the implementation of these custom hooks; what I want to focus on is how these hooks change the code when they are used instead of custom fetch() calls:

import * as React from 'react';
-import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
+import { useGetOne, useUpdate } from 'b2b-framework';
import { Card, TextField, Button, Stack, MenuItem } from '@mui/material';

export const BookEdit = () => {
    const { id } = useParams();
    const { handleSubmit, reset, control } = useForm();

    // load book record on mount
-   const [isLoading, setIsLoading] = useState(true);
-   useEffect(() => {
-       fetch(`/api/books/${id}`)
-           .then(res => res.json())
-           .then(({ data }) => {
-               // initialize form with the result
-               reset(data)
-               setIsLoading(false);
-           });
-   }, [id]);
+   const { isLoading } = useGetOne('books', { id }, {
+       onSuccess: (data) => reset(data)
+   });

    // update book record on submit
-   const [isSubmitting, setIsSubmitting] = useState(false);
+   const [update, { isLoading: isSubmitting }] = useUpdate();
    const navigate = useNavigate();
    const onSubmit = (data) => {
-       setIsSubmitting(true);
-       const options = {
-           body: JSON.stringify({ data })
-           method: 'POST',
-           headers: { 'Content-Type': 'application/json' },
-       };
-       fetch(`/api/books/${id}`, options)
-           .then(() => {
-               setIsSubmitting(false);
-               navigate('/books');
-           });
+       update('books', { id, data }, {
+           onSuccess: () => { navigate('/books'); }
+       });
    };

    // ... no change to the rest of the component
};

The custom hooks are provided by an imaginary package that I'll call b2b-framework. As the API fetching logic is now handled by these hooks, it should be easier to add authentication and error handling there.

Removing useState and useEffect removes some state from the code and reduces the code volume. It's a great first step towards removing complexity. But we can do better.

<Form>: Form Logic

To use react-hook-form with MUI inputs, the previous example leverages the <Controller> tag, which expects a control object generated by the useForm hook (see the related react-hook-form doc).

We can avoid the call to useForm by putting its logic inside a custom component. Let' call it <Form>. It should expect a record attribute to initialize the inputs based on the record fields - and reset when the record changes. <Form> should also create a react-hook-form <FormProvider>, so we no longer need to pass the control prop to each <Controller> element:

import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { useForm, Controller } from 'react-hook-form';
+import { Controller } from 'react-hook-form';
-import { useGetOne, useUpdate, Title } from 'b2b-framework';
+import { useGetOne, useUpdate, Title, Form } from 'b2b-framework';
import { Card, TextField, Stack, MenuItem } from '@mui/material';

export const BookEdit = () => {
    const { id } = useParams();
-   const { handleSubmit, reset, control } = useForm();
-   const { isLoading } = useGetOne('books', { id }, {
-       onSuccess: (data) => reset(data)
-   });
+   const { isLoading, data } = useGetOne('books', { id });
    const [update, { isLoading: isSubmitting }] = useUpdate();
    const navigate = useNavigate();
    const onSubmit = (data) => {
        update('books', { id, data }, {
            onSuccess: () => { navigate('/books'); }
        });
    };
    if (isLoading) return null;
    return (
        <div>
            <Title title="Book Edition" />
            <Card>
-               <form onSubmit={handleSubmit(onSubmit)}>
+               <Form record={data} onSubmit={onSubmit}>
                    <Stack spacing={2}>
                        <Controller
                            name="title"
                            render={({ field }) => <TextField label="Title" {...field} />}
-                           control={control}
                        />
                        <Controller
                            name="author"
                            render={({ field }) => <TextField label="Author" {...field} />}
-                           control={control}
                        />
                        <Controller
                            name="availability"
                            render={({ field }) => (
                                <TextField select label="Availability" {...field}>
                                    <MenuItem value="in_stock">In stock</MenuItem>
                                    <MenuItem value="out_of_stock">Out of stock</MenuItem>
                                    <MenuItem value="out_of_print">Out of print</MenuItem>
                                </TextField>
                            )}
-                           control={control}
                        />
                        <Button type="submit" disabled={isSubmitting}>
                            Save
                        </Button>
                    </Stack>
-               </form>
+               </Form>
            </Card>
        </div>
    );
};

<SimpleForm>: Stacked Layout

Displaying inputs in a <Stack> is a common UI pattern that appears in many forms. Let's introduce <SimpleForm>, a convenience wrapper around <Form> that provides this stacked layout. It should include a submit button, so the BookEdit component code can be more focused on business logic.

import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Controller } from 'react-hook-form';
-import { useGetOne, useUpdate, Title, Form } from 'b2b-framework';
+import { useGetOne, useUpdate, Title, SimpleForm } from 'b2b-framework';
-import { Card, TextField, Stack, MenuItem } from '@mui/material';
+import { Card, TextField, MenuItem } from '@mui/material';

export const BookEdit = () => {
    const { id } = useParams();
    const { isLoading, data } = useGetOne("books", { id });
    const [update, { isLoading: isSubmitting }] = useUpdate();
    const navigate = useNavigate();
    const onSubmit = (data) => {
        update('books', { id, data }, {
            onSuccess: () => { navigate('/books'); }
        });
    };
    if (isLoading) return null;
    return (
        <div>
            <Title title="Book Edition" />
            <Card>
-               <Form record={data} onSubmit={onSubmit}>
+               <SimpleForm record={data} onSubmit={onSubmit} saving={isSubmitting}>
-                   <Stack spacing={2}>
                        <Controller
                            name="title"
                            render={({ field }) => <TextField label="Title" {...field} />}
                        />
                        <Controller
                            name="author"
                            render={({ field }) => <TextField label="Author" {...field} />}
                        />
                        <Controller
                            name="availability"
                            render={({ field }) => (
                            <TextField select label="Availability" {...field}>
                                <MenuItem value="in_stock">In stock</MenuItem>
                                <MenuItem value="out_of_stock">Out of stock</MenuItem>
                                <MenuItem value="out_of_print">Out of print</MenuItem>
                            </TextField>
                            )}
                        />
-                       <Button type="submit" disabled={isSubmitting}>
-                           Save
-                       </Button>
-                   </Stack>
-               </Form>
+               </SimpleForm>
            </Card>
        </div>
    );
};

<SimpleForm> is a layout component. It should contain only presentation logic, delegating the actual form handling to the underlying <Form> component.

Using Input Components

Wrapping form inputs with a <Controller> tag is a common pattern, so let's introduce "Input components", which do it in a reusable way:

  • <TextInput> wraps a <TextInput> inside a <Controller>
  • <SelectInput> wraps a <TextInput select> inside a <Controller>

This means the BookEdit component doesn't need to use react-hook-form's <Controller> directly:

import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { Controller } from 'react-hook-form';
-import { useGetOne, useUpdate, Title, SimpleForm } from 'b2b-framework';
+import { useGetOne, useUpdate, Title, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
-import { Card, TextField, MenuItem } from '@mui/material';
+import { Card } from '@mui/material';

export const BookEdit = () => {
    const { id } = useParams();
    const { isLoading, data } = useGetOne("books", { id });
    const [update, { isLoading: isSubmitting }] = useUpdate();
    const navigate = useNavigate();
    const onSubmit = (data) => {
        update('books', { id, data }, {
            onSuccess: () => { navigate('/books'); }
        });
    };
    if (isLoading) return null;
    return (
        <div>
            <Title title="Book Edition" />
            <Card>
                <SimpleForm record={data} onSubmit={onSubmit} saving={isSubmitting}>
-                   <Controller
-                      name="title"
-                       render={({ field }) => <TextField label="Title" {...field} />}
-                   />
+                   <TextInput source="title" />
-                   <Controller
-                       name="author"
-                       render={({ field }) => <TextField label="Author" {...field} />}
-                   />
+                   <TextInput source="author" />
-                   <Controller
-                       name="availability"
-                       render={({ field }) => (
-                           <TextField select label="Availability" {...field}>
-                               <MenuItem value="in_stock">In stock</MenuItem>
-                               <MenuItem value="out_of_stock">Out of stock</MenuItem>
-                               <MenuItem value="out_of_print">Out of print</MenuItem>
-                           </TextField>
-                       )}
-                   />
+                   <SelectInput source="availability" choices={[
+                       { id: "in_stock", name: "In stock" },
+                       { id: "out_of_stock", name: "Out of stock" },
+                       { id: "out_of_print", name: "Out of print" },
+                   ]} />
                </SimpleForm>
            </Card>
        </div>
    );
};

That's a lot better: removing the Controller render prop also reduces the mental overhead of inline functions (and their succession of cryptic symbols like {({).

<EditContext> Exposes Data And Callbacks

Instead of passing the record and onSubmit callback to the <SimpleForm> element, we could put them in a custom React context - let's call it EditContext. <SimpleForm> would read record and onSubmit from that context. This allows any descendant of <SimpleForm> element to read the record without having to pass it down component by component:

import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
-import { useGetOne, useUpdate, Title, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
+import { useGetOne, useUpdate, Title, EditContextProvider, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
import { Card } from '@mui/material';

export const BookEdit = () => {
    const { id } = useParams();
    const { isLoading, data } = useGetOne("books", { id });
    const [update, { isLoading: isSubmitting }] = useUpdate();
    const navigate = useNavigate();
    const onSubmit = (data) => {
        update('books', { id, data }, {
            onSuccess: () => { navigate('/books'); }
        });
    };
    if (isLoading) return null;
    return (
+       <EditContextProvider value={{
+           record: data,
+           isLoading,
+           save: onSubmit,
+           saving: isSubmitting,
+       }}>
            <div>
                <Title title="Book Edition" />
                <Card>
-               <SimpleForm record={data} onSubmit={onSubmit} saving={isSubmitting}>
+                   <SimpleForm>
                        <TextInput source="title" />
                        <TextInput source="author" />
                        <SelectInput source="availability" choices={[
                            { id: "in_stock", name: "In stock" },
                            { id: "out_of_stock", name: "Out of stock" },
                            { id: "out_of_print", name: "Out of print" },
                        ]} />
                    </SimpleForm>
                </Card>
            </div>
+       </EditContextProvider>
    );
};

This may look a bit more verbose, but as <SimpleForm> knows how to execute in an EditContext, it "pulls" the data and callback from the context instead of expecting the developer to "push" it to it. The components are smarter, so the developer doesn't have to manage communication between them. Once again, the complexity decreases.

Note that this is a form of inversion of control.

useEditController: The Controller Logic

This ContextProvider highlights the fact that the JSX needs several pieces of information to be able to render the form. The code that prepares this information can be seen as the "controller" part of the component (according to the Model-View-Controller design pattern). It contains the initial logic that grabs the id from the location, fetches the record from the API, and builds the save callback.

Let's extract it to a hook called useEditController, which can be reused by all other edition views:

import * as React from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
-import { useGetOne, useUpdate, Title, EditContextProvider, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
+import { useEditController, Title, EditContextProvider, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
import { Card } from "@mui/material";

export const BookEdit = () => {
-   const { id } = useParams();
-   const { isLoading, data } = useGetOne("books", { id });
-   const [update, { isLoading: isSubmitting }] = useUpdate();
-   const navigate = useNavigate();
-   const onSubmit = (data) => {
-       update('books', { id, data }, {
-           onSuccess: () => { navigate('/books'); }
-       });
-   };
+   const editContext = useEditController();
-   if (isLoading) return null;
+   if (editContext.isLoading) return null;
    return (
-       <EditContextProvider value={{
-           record: data,
-           isLoading,
-           save: onSubmit,
-           saving: isSubmitting,
-       }}>
+       <EditContextProvider value={editContext}>
            <div>
                <Title title="Book Edition" />
                <Card>
                <SimpleForm>
                    <TextInput source="title" />
                    <TextInput source="author" />
                    <SelectInput source="availability" choices={[
                        { id: "in_stock", name: "In stock" },
                        { id: "out_of_stock", name: "Out of stock" },
                        { id: "out_of_print", name: "Out of print" },
                    ]} />
                </SimpleForm>
                </Card>
            </div>
        </EditContextProvider>
    );
};

Notice that useEditController shouldn't need the resource name ('books' in this case), as it can guess it from the URL (if the component is rendered in the /books/123 route).

<EditBase>: Component Version Of The Controller

Calling a controller and putting its result into a context is basically what every page should do. We should be able to refactor that logic in another component - let's call it <EditBase>. It would manage the useEditController call and the EditContextProvider call:

import * as React from 'react';
-import { useEditController, Title, EditContextProvider, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
+import { EditBase, Title, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
import { Card } from "@mui/material";

export const BookEdit = () => {
-   const editContext = useEditController();
-   if (editContext.isLoading) return null;
    return (
-       <EditContextProvider value={editContext}>
+       <EditBase>
            <div>
                <Title title="Book Edition" />
                <Card>
                <SimpleForm>
                    <TextInput source="title" />
                    <TextInput source="author" />
                    <SelectInput source="availability" choices={[
                        { id: "in_stock", name: "In stock" },
                        { id: "out_of_stock", name: "Out of stock" },
                        { id: "out_of_print", name: "Out of print" },
                    ]} />
                </SimpleForm>
                </Card>
            </div>
-       </EditContextProvider>
+       </EditBase>
    );
};

<Edit> Renders Title, Fields, And Actions

<EditBase> is a headless component: it renders only its children. But almost every edition view needs a wrapping <div>, a title, and a <Card>. To let developers focus on business logic, let's extract that common UI into an <Edit> component:

import * as React from 'react';
-import { EditBase, Title, SimpleForm, TextInput, SelectInput } from 'b2b-framework';
+import { Edit, SimpleForm, TextInput, SelectInput } from 'b2b-framework';

export const BookEdit = () => (
-   <EditBase>
-       <div>
-           <Title title="Book Edition" />
-           <Card>
+   <Edit>
        <SimpleForm>
        <TextInput source="title" />
        <TextInput source="author" />
        <SelectInput source="availability" choices={[
            { id: "in_stock", name: "In stock" },
            { id: "out_of_stock", name: "Out of stock" },
            { id: "out_of_print", name: "Out of print" },
        ]} />
        </SimpleForm>
-           </Card>
-       </div>
-   </EditBase>
+   </Edit>
);

And that's about it. The remaining code is pure business logic (the form layout, which inputs to render, with their names and options). There is nothing left to remove.

A Framework From First Principles

The process explained in this article is exactly what lead us to build react-admin in the first place. It shows that there is no magic in the framework - it stems from first principles. In fact, just replace b2b-framework with react-admin in the code above and the app will work seamlessly.

import * as React from 'react';
import { Edit, SimpleForm, TextInput, SelectInput } from 'react-admin';

export const BookEdit = () => (
    <Edit>
        <SimpleForm>
            <TextInput source="title" />
            <TextInput source="author" />
            <SelectInput
                source="availability"
                choices={[
                    { id: 'in_stock', name: 'In stock' },
                    { id: 'out_of_stock', name: 'Out of stock' },
                    { id: 'out_of_print', name: 'Out of print' },
                ]}
            />
        </SimpleForm>
    </Edit>
);

All the custom hooks and components exposed in this article are actually exported by react-admin. Click on their name to see their documentation:

The react example had more than 90 lines of code. Once refactored using react-admin, the code is concise (20 lines of code), expressive, and easier to maintain. It's declarative, and hides the burden of handling state:

React-admin uses 2/3 of the techniques from Out of the Tar Pit (functional programming and declarative programming - we don't use object-oriented programming). The final code example shows how effective these techniques are at reducing complexity, and how they help developers focus on business logic.

But to be honest, until version 4, the react-admin code wasn't clean enough to let this architecture appear. The components were more coupled, and their responsibilities were less separated. It would have been impossible to write such an article with react-admin v3.

Build Your Own Framework

This article also shows that <Edit> and <SimpleForm> use lower-level components (<EditBase>, <Form>) and hooks (useEditController, useForm) that you can also use if you only need partial functionality. We've designed react-admin v4 to be very hackable and to let developers replace any part they want with their own code.

In fact, you could very well build your own framework using only the react-admin hooks and another UI library. We've organized the code to make that possible: most hooks live in a sub-package called ra-core, and the MUI wrappers live in a sub-package called ra-ui-materialui.

With a cleaner architecture, react-admin v4 is the framework you would build yourself if you were to develop complex B2B apps repeatedly - and if you had a lot of time!

Because the actual react-admin hooks and components that appear in the final code (<Edit, <SimpleForm>, <TextInput>, <SelectInput>) do much more than what the initial code did. They handle error cases, client-side and server-side validation, error notifications, UI variants, i18n, theming, authentications, etc.

Conclusion

When developers discover react-admin, they sometimes say:

I don't need the complexity of that framework. I'd rather do things in pure React.

What I tried to demonstrate in this article is that react-admin removes the complexity of your application code.

Making things simple is hard - that's why react-admin v4 was 6 months in the making. More than any previous version, react-admin v4 lets you be super productive, without spending time on problems that were already solved by others.

Did you like this article? Share it!