Introduction
Ra-core provides hooks and components to let you build custom user experiences for editing and creating records, leveraging react-hook-form.
An Edition View Built By Hand
Section titled “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.
To better understand how to use the various ra-core hooks and components dedicated to editing and creating, let’s start by building such an edition view by hand.
Here is how you could write a book edition view in pure React, leveraging ra-core’s data fetching hooks, and react-hook-form to bind form inputs with a record object:
import * as React from "react";import { useParams, useNavigate } from "react-router-dom";import { useForm, Controller } from "react-hook-form";import { useGetOne, useUpdate } from "ra-core";
export const BookEdit = () => { const { id } = useParams(); const { handleSubmit, reset, control } = useForm(); const { isPending } = useGetOne( "books", { id }, { onSuccess: (data) => reset(data) } ); const [update, { isPending: isSubmitting }] = useUpdate(); const navigate = useNavigate(); const onSubmit = (data) => { update( "books", { id, data }, { onSuccess: () => { navigate('/books'); } } ); };
if (isPending) return null; return ( <div> <form onSubmit={handleSubmit(onSubmit)}> <div> <Controller name="title" render={({ field }) => ( <div> <label htmlFor="title">Title</label> <input id="title" {...field} /> </div> )} control={control} /> <Controller name="author" render={({ field }) => ( <div> <label htmlFor="author">Author</label> <input id="author" {...field} /> </div> )} control={control} /> <Controller name="availability" render={({ field }) => ( <div> <label htmlFor="availability">Availability</label> <select id="availability" {...field}> <option value="in_stock">In stock</option> <option value="out_of_stock">Out of stock</option> <option value="out_of_print">Out of print</option> </select> </div> )} control={control} /> <button type="submit" disabled={isSubmitting}> Save </button> </div> </form> </div> );};
This form displays 3 inputs (two text inputs and one select input), and redirects to the book list view upon successful submission. It doesn’t even contain default values, validation, or dependent inputs. Yet, it’s already quite verbose.
It’s a super common component. In fact, many of its features could be extracted for reuse in other pages. Let’s see how to improve the code and the developer experience in the same process.
<Form>
: Form Logic
Section titled “<Form>: Form Logic”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. That’s exactly what ra-core’s <Form>
component does. <Form>
also creates 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 } from "ra-core";import { useGetOne, useUpdate, Form } from "ra-core";
export const BookEdit = () => { const { id } = useParams(); const { handleSubmit, reset, control } = useForm(); const { isPending } = useGetOne( const { isPending, data } = useGetOne( "books", { id }, { onSuccess: (data) => reset(data) } ); const [update, { isPending: isSubmitting }] = useUpdate(); const navigate = useNavigate(); const onSubmit = (data) => { update( "books", { id, data }, { onSuccess: () => { navigate('/books'); } } ); }; if (isPending) return null; return ( <div> <form onSubmit={handleSubmit(onSubmit)}> <Form record={data} onSubmit={onSubmit}> <div> <Controller name="title" render={({ field }) => ( <div> <label htmlFor="title">Title</label> <input id="title" {...field} /> </div> )} control={control} /> <Controller name="author" render={({ field }) => ( <div> <label htmlFor="author">Author</label> <input id="author" {...field} /> </div> )} control={control} /> <Controller name="availability" render={({ field }) => ( <div> <label htmlFor="availability">Availability</label> <select id="availability" {...field}> <option value="in_stock">In stock</option> <option value="out_of_stock">Out of stock</option> <option value="out_of_print">Out of print</option> </select> </div> )} control={control} /> <button type="submit" disabled={isSubmitting}> Save </button> </div> </form> </Form> </div> );};
useInput
: Form Logic Made Easy
Section titled “useInput: Form Logic Made Easy”Instead of using <Controller>
, you can use the useInput
hook to bind your inputs to the form values. This hook provides all the necessary props for your input components:
import * as React from "react";import { useParams, useNavigate } from "react-router-dom";import { Controller } from "react-hook-form";import { useGetOne, useUpdate, Form } from "ra-core";import { useGetOne, useUpdate, Form, useInput} from "ra-core";
const TitleInput = () => { const { field } = useInput({ source: 'title' }); return ( <div> <label htmlFor="title">Title</label> <input id="title" {...field} /> </div> ); }; const AuthorInput = () => { const { field } = useInput({ source: 'author' }); return ( <div> <label htmlFor="author">Author</label> <input id="author" {...field} /> </div> ); }; const AvailabilityInput = () => { const { field } = useInput({ source: 'availability' }); return ( <div> <label htmlFor="availability">Availability</label> <select id="availability" {...field}> <option value="in_stock">In stock</option> <option value="out_of_stock">Out of stock</option> <option value="out_of_print">Out of print</option> </select> </div> ); };
export const BookEdit = () => { const { id } = useParams(); const { isPending, data } = useGetOne( "books", { id } ); const [update, { isPending: isSubmitting }] = useUpdate(); const navigate = useNavigate(); const onSubmit = (data) => { update( "books", { id, data }, { onSuccess: () => { navigate('/books'); } } ); };
if (isPending) return null; return ( <div> <Form record={data} onSubmit={onSubmit}> <div> <Controller name="title" render={({ field }) => ( <div> <label htmlFor="title">Title</label> <input id="title" {...field} /> </div> )} /> <Controller name="author" render={({ field }) => ( <div> <label htmlFor="author">Author</label> <input id="author" {...field} /> </div> )} /> <Controller name="availability" render={({ field }) => ( <div> <label htmlFor="availability">Availability</label> <select id="availability" {...field}> <option value="in_stock">In stock</option> <option value="out_of_stock">Out of stock</option> <option value="out_of_print">Out of print</option> </select> </div> )} /> <TitleInput /> <AuthorInput /> <AvailabilityInput /> <button type="submit" disabled={isSubmitting}> Save </button> </div> </Form> </div> );};
The useInput
hook provides form logic in a more declarative way than <Controller>
. It takes care of:
- Binding the input to the form values
- Handling validation
- Managing the form and input state
Input Components
Section titled “Input Components”To save time and avoid repetition, you can extract common form input patterns into reusable components. This is a great way to maintain consistency across your forms and reduce boilerplate:
// in src/common/inputs/TextInput.tsximport { useInput } from 'ra-core';
export const TextInput = ({ source, label }) => { const { field } = useInput({ source }); return ( <div> <label htmlFor={source}>{label}</label> <input id={source} {...field} /> </div> );};
// in src/common/inputs/SelectInput.tsximport { useInput } from 'ra-core';
export const SelectInput = ({ source, label, choices }) => { const { field } = useInput({ source }); return ( <div> <label htmlFor={source}>{label}</label> <select id={source} {...field}> {choices.map(choice => ( <option key={choice.id} value={choice.id}> {choice.name} </option> ))} </select> </div> );};
// in src/books/BookEdit.tsximport * as React from "react";import { useParams, useNavigate } from "react-router-dom";import { useGetOne, useUpdate, Form } from "ra-core";import { TextInput } from "../common/inputs/TextInput";import { SelectInput } from "../common/inputs/SelectInput";
export const BookEdit = () => { const { id } = useParams(); const { isPending, data } = useGetOne("books", { id }); const [update, { isPending: isSubmitting }] = useUpdate(); const navigate = useNavigate(); const onSubmit = (data) => { update( "books", { id, data }, { onSuccess: () => { navigate('/books'); } } ); }; if (isPending) return null; return ( <div> <Form record={data} onSubmit={onSubmit}> <div> <TextInput source="title" label="Title" /> <TextInput source="author" label="Author" /> <SelectInput source="availability" label="Availability" choices={[ { id: "in_stock", name: "In stock" }, { id: "out_of_stock", name: "Out of stock" }, { id: "out_of_print", name: "Out of print" }, ]} /> <button type="submit" disabled={isSubmitting}> Save </button> </div> </Form> </div> );};
<EditContext>
Exposes Data And Callbacks
Section titled “<EditContext> Exposes Data And Callbacks”Instead of passing the record
and onSubmit
callback directly to the <Form>
element, ra-core provides an <EditContext>
context. This allows any descendant element to access the data and callback from the context.
import * as React from "react";import { useParams, useNavigate } from "react-router-dom";import { useGetOne, useUpdate, Form } from "ra-core";import { useGetOne, useUpdate, EditContextProvider, Form } from "ra-core";import { TextInput } from "../common/inputs/TextInput";import { SelectInput } from "../common/inputs/SelectInput";
export const BookEdit = () => { const { id } = useParams(); const { isPending, data } = useGetOne("books", { id }); const [update, { isPending: isSubmitting }] = useUpdate(); const navigate = useNavigate(); const onSubmit = (data) => { update( "books", { id, data }, { onSuccess: () => { navigate('/books'); } } ); }; if (isPending) return null; return ( <EditContextProvider value={{ record: data, isPending, save: onSubmit, saving: isSubmitting, }}> <div> <Form record={data} onSubmit={onSubmit}> <Form> <div> <TextInput source="title" label="Title" /> <TextInput source="author" label="Author" /> <SelectInput source="availability" label="Availability" choices={[ { id: "in_stock", name: "In stock" }, { id: "out_of_stock", name: "Out of stock" }, { id: "out_of_print", name: "Out of print" }, ]} /> <button type="submit" disabled={isSubmitting}> Save </button> </div> </Form> </div> </EditContextProvider> );};
Thanks to <EditContextProvider>
, the <Form>
component and its child inputs no longer need explicit props. This may look more verbose at first, but having a standardized EditContext
value simplifies the API and enables further improvements, explained below.
useEditController
: The Controller Logic
Section titled “useEditController: The Controller Logic”The initial logic that grabs the ID from the location, fetches the record from the API, and prepares the save
callback is common across edit views. Ra-core exposes the useEditController
hook to handle this logic:
import * as React from "react";import { useParams, useNavigate } from "react-router-dom";import { useGetOne, useUpdate, EditContextProvider, Form } from "ra-core";import { useEditController, EditContextProvider, Form } from "ra-core";import { TextInput } from "../common/inputs/TextInput";import { SelectInput } from "../common/inputs/SelectInput";
export const BookEdit = () => { const { id } = useParams(); const { isPending, data } = useGetOne("books", { id }); const [update, { isPending: isSubmitting }] = useUpdate(); const navigate = useNavigate(); const onSubmit = (data) => { update( "books", { id, data }, { onSuccess: () => { navigate('/books'); } } ); }; const editContext = useEditController(); if (isPending) return null; if (editContext.isPending) return null; return ( <EditContextProvider value={{ record: data, isPending, save: onSubmit, saving: isSubmitting, }}> <EditContextProvider value={editContext}> <div> <Form> <div> <TextInput source="title" label="Title" /> <TextInput source="author" label="Author" /> <SelectInput source="availability" label="Availability" choices={[ { id: "in_stock", name: "In stock" }, { id: "out_of_stock", name: "Out of stock" }, { id: "out_of_print", name: "Out of print" }, ]} /> <button type="submit" disabled={editContext.saving}> Save </button> </div> </Form> </div> </EditContextProvider> );};
Notice that useEditController
doesn’t need the ‘books’ resource name - it relies on the ResourceContext
, set by the <Resource>
component, to guess it.
<EditBase>
: Component Version Of The Controller
Section titled “<EditBase>: Component Version Of The Controller”Since calling useEditController
and putting its result into a context is a common pattern, ra-core provides the <EditBase>
component to do it. This allows us to further simplify the example:
import * as React from "react";import { useEditController, EditContextProvider, Form } from "ra-core";import { EditBase, Form } from "ra-core";import { TextInput } from "../common/inputs/TextInput";import { SelectInput } from "../common/inputs/SelectInput";
export const BookEdit = () => { const editContext = useEditController(); if (editContext.isPending) return null; return ( <EditContextProvider value={editContext}> <EditBase> <div> <Form> <div> <TextInput source="title" label="Title" /> <TextInput source="author" label="Author" /> <SelectInput source="availability" label="Availability" choices={[ { id: "in_stock", name: "In stock" }, { id: "out_of_stock", name: "Out of stock" }, { id: "out_of_print", name: "Out of print" }, ]} /> <button type="submit">Save</button> </div> </Form> </div> </EditContextProvider> </EditBase> );};
A Complete Edit View
Section titled “A Complete Edit View”With all these components, we can build a complete, maintainable edit view:
// in src/books/BookEdit.tsximport * as React from "react";import { EditBase, Form, useEditController } from "ra-core";import { TextInput } from "../common/inputs/TextInput";import { SelectInput } from "../common/inputs/SelectInput";
export const BookEdit = () => ( <EditBase> <div> <h1>Edit Book</h1> <Form> <div> <TextInput source="title" label="Title" /> <TextInput source="author" label="Author" /> <SelectInput source="availability" label="Availability" choices={[ { id: "in_stock", name: "In stock" }, { id: "out_of_stock", name: "Out of stock" }, { id: "out_of_print", name: "Out of print" }, ]} /> <button type="submit">Save</button> </div> </Form> </div> </EditBase>);
The code is now concise, maintainable, and contains all the necessary logic for:
- Fetching the record from the API
- Populating the form with the record data
- Handling form submission and validation
- Managing loading and error states
- Redirecting after success
Ra-core’s headless components provide a robust foundation for building custom user interfaces while taking care of the complex data management logic under the hood.