Advanced Edit

Drop to the edit controller, transform values, and guard unsaved changes.

<Edit> wraps the edit-form controller (useEditForm). When you need control the view doesn't expose — a bespoke layout, a transform before persist, a different redirect — read the controller directly.

The edit controller

useEditForm(resource, { id }) fetches the record, builds the form store, and returns the form, the record, and a submit handler. The <Edit> view is a thin shell over it; a custom edit page reads the same pieces.

Under an <Edit>, useEditContext reads what the view published. Give it the record type to type record:

import { useEditContext } from "@chelone/react";
 
function EditHeading() {
  const { record, isPending } = useEditContext<{ title: string }>();
  if (isPending) return <p>Loading…</p>;
  return <h1>Editing “{record?.title}”</h1>;
}

useEditContext reads the context an ancestor <Edit> published and throws without one; useEditForm constructs a fresh controller when you're building the whole page yourself.

Transforming values before save

To reshape the form values before they hit the data provider (compute a slug, stamp a timestamp, drop a UI-only field), pass transform to useEditForm. The controller runs it inside the save pipeline, after validation, and sends what it returns. The second argument carries previousData, the record as fetched. Server ValidationErrors, the notification, the form reset and the redirect all keep working, which a hand-written useUpdate submit would have to redo.

<Edit> has no transform prop, so a page that transforms builds its own shell: mount the form provider yourself and render the inputs once the record has loaded.

import {
  CheloneFormProvider,
  SaveButton,
  TextInput,
  useEditForm,
  useParams,
} from "@chelone/react";
 
function PostEditWithSlug() {
  const { id } = useParams();
  const { form, submit, record, error } = useEditForm("posts", {
    id: id as string,
    transform: (data, { previousData } = {}) => ({
      ...data,
      slug: String(data.title ?? previousData?.title ?? "")
        .toLowerCase()
        .replace(/\s+/g, "-"),
    }),
  });
  if (error) return <p>Could not load the post.</p>;
  if (!record) return <p>Loading…</p>;
  return (
    <CheloneFormProvider form={form} submit={submit}>
      <TextInput source="title" />
      <SaveButton />
    </CheloneFormProvider>
  );
}

useEditForm also takes onRedirect. A save redirects to the list by default; pass onRedirect: () => {} to stay on the page. That also silences the redirect to the list after a failed load, so render the error yourself, as the example does.

The controller reads its options once, on mount. Remount it with a key to change them.

Guarding unsaved changes

useWarnWhenUnsavedChanges(enable) watches the form it sits in and blocks navigation away from it while it holds unsaved changes. A successful save clears the block. It also asks the browser to confirm when the tab closes or reloads.

The hook renders nothing. It returns { isBlocked, proceed, cancel }, and the dialog is yours: show one while isBlocked is true, call proceed to discard the changes and continue, or cancel to stay. Call it from a component inside <Edit>:

import {
  Confirm,
  Edit,
  TextInput,
  useWarnWhenUnsavedChanges,
} from "@chelone/react";
 
function UnsavedChangesDialog() {
  const { isBlocked, proceed, cancel } = useWarnWhenUnsavedChanges(true);
  return (
    <Confirm
      isOpen={isBlocked}
      title="Discard unsaved changes?"
      content="Your edits will be lost if you leave this page."
      onConfirm={proceed}
      onClose={cancel}
    />
  );
}
 
export function PostEdit() {
  return (
    <Edit>
      <TextInput source="title" />
      <UnsavedChangesDialog />
    </Edit>
  );
}

Navigation inside the form's own route is allowed, except to its /show view. The optional second argument sets the form's route root explicitly. When the host router cannot block navigation (a declarative <BrowserRouter> around <Admin>), the hook logs a warning and returns a decision that never blocks; the tab-close prompt still works.

Form context

Inside any form you can read and drive the form store with the form hooks: useFormContext (the form instance), useWatch (subscribe to a field), useController / useFieldArray (bind a custom control or a repeatable group). These are the same primitives the inputs are built on.

Tips

  • Prefer <Edit> + composed inputs first; reach for useEditForm only when the page is fundamentally custom.
  • useEditContext (consume) vs useEditForm (construct) — pick by whether an <Edit> ancestor already exists.