Skip to content

Introduction

Ra-core provides hooks and components to let you build custom user experiences for editing and creating records, leveraging react-hook-form.

Edit view example

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.

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>
);
};

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

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.tsx
import { 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.tsx
import { 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.tsx
import * 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>
);
};

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.

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>
);
};

With all these components, we can build a complete, maintainable edit view:

// in src/books/BookEdit.tsx
import * 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.