ra-core-ee

ra-core ≥ 5.11.0

Headless hooks and components for building enterprise-grade applications with React Admin.

Installation

npm install --save @react-admin/ra-core-ee
# or
yarn add @react-admin/ra-core-ee

Tip: ra-core-ee is part of the React-Admin Enterprise Edition, and hosted in a private npm registry. You need to subscribe to one of the Enterprise Edition plans to access this package.

Authorization

getPermissionsFromRoles

This function returns an array of user permissions based on a role definition, a list of roles, and a list of user permissions. It merges the permissions defined in roleDefinitions for the current user's roles (userRoles) with the extra userPermissions.

// static role definitions (usually in the app code)
const roleDefinitions = {
    admin: [
        { action: '*', resource: '*' }
    ],
    reader: [
        { action: ['list', 'show', 'export'], resource: '*' }
        { action: 'read', resource: 'posts.*' }
        { action: 'read', resource: 'comments.*' }
    ],
    accounting: [
        { action: '*', resource: 'sales' },
    ],
};

const permissions = getPermissionsFromRoles({
    roleDefinitions,
    // roles of the current user (usually returned by the server upon login)
    userRoles: ['reader'],
    // extra permissions for the current user (usually returned by the server upon login)
    userPermissions: [
        { action: 'list', resource: 'sales'},
    ],
});
// permissions = [
//  { action: ['list', 'show', 'export'], resource: '*' },
//  { action: 'read', resource: 'posts.*' },
//  { action: 'read', resource: 'comments.*' },
//  { action: 'list', resource: 'sales' },
// ];

This function takes an object as argument with the following fields:

  • roleDefinitions: a dictionary containing the role definition for each role
  • userRoles (optional): an array of roles (admin, reader...) for the current user
  • userPermissions (optional): an array of permissions for the current user

canAccessWithPermissions

canAccessWithPermissions is a helper that facilitates the authProvider.canAccess() method implementation:

import { canAccessWithPermissions } from '@react-admin/ra-core-ee';

const authProvider = {
    // ...
    canAccess: async ({ action, resource, record }) => {
        const permissions = JSON.parse(localStorage.getItem('permissions'));
        return canAccessWithPermissions({
            permissions,
            action,
            resource,
            record,
        });
    }
};
import { canAccessWithPermissions } from "@react-admin/ra-core-ee";

const authProvider = {
    // ...
    canAccess: async ({ action, resource, record }) => {
        const permissions = JSON.parse(localStorage.getItem("permissions"));
        return canAccessWithPermissions({
            permissions,
            action,
            resource,
            record,
        });
    },
};

canAccessWithPermissions expects the permissions to be a flat array of permissions. It is your responsibility to fetch these permissions (usually during login). If the permissions are spread into several role definitions, you can merge them into a single array using the getPermissionsFromRoles function.

Realtime

Features

Publish/Subscribe

At its core, @react-admin/ra-core-ee provides a pub/sub mechanism to send and receive real-time events. Events are sent to a topic, and all subscribers to this topic receive the event.

// on the publisher side
const [publish] = usePublish();
publish(topic, event);

// on the subscriber side
useSubscribe(topic, callback);
// on the publisher side
const [publish] = usePublish();
publish(topic, event);

// on the subscriber side
useSubscribe(topic, callback);

This package supports various realtime infrastructures (Mercure, API Platform, supabase, Socket.IO, Ably, and many more) thanks to the same adapter approach as for CRUD methods. In fact, the dataProvider is used to send and receive events (see the Data Provider Requirements section for more information).

@react-admin/ra-core-ee provides a set of high-level hooks to make it easy to work with real-time events:

Live Updates

Ra-realtime provides live updates via specialized hooks and components. This means that when a user edits a resource, the other users working on the same resource see the changes in real-time whether they are in a list, a show view, or an edit view.

For instance, include a <ListLiveUpdate> within a <ListBase> to have a list refreshing automatically when an element is added, updated, or deleted:

import { ListBase } from 'ra-core';
+import { ListLiveUpdate } from '@react-admin/ra-realtime';

const PostList = () => (
    <ListBase>
        ...other children
+       <ListLiveUpdate />
    </ListBase>
);

This feature leverages the following hooks:

Locks

And last but not least, @react-admin/ra-core-ee provides a lock mechanism to prevent two users from editing the same resource at the same time.

A user can lock a resource, either by voluntarily asking for a lock or by editing a resource. When a resource is locked, other users can't edit it. When the lock is released, other users can edit the resource again.

import  { Form, useCreate, useGetIdentity, useRecordContext } from 'ra-core';
import  { useGetLockLive } from '@react-admin/ra-core-ee';
import { TextInput, SelectInput } from '@components/admin/TextInput';

export const NewMessageForm = () => {
    const [create, { isLoading: isCreating }] = useCreate();
    const record = useRecordContext();

    const { data: lock } = useGetLockLive('tickets', { id: record.id });
    const { identity } = useGetIdentity();
    const isFormDisabled = lock && lock.identity !== identity?.id;

    const [doLock] = useLockOnCall({ resource: 'tickets' });
    const handleSubmit = (values: any) => {
        /* ... */
    };

    return (
        <Form onSubmit={handleSubmit}>
            <TextInput
                source="message"
                multiline
                onFocus={() => {
                    doLock();
                }}
                disabled={isFormDisabled}
            />
            <SelectInput
                source="status"
                choices={statusChoices}
                disabled={isFormDisabled}
            />
            <button type="submit" disabled={isCreating || isFormDisabled}>
                Submit
            </button>
        </Form>
    );
};
import { Form, useCreate, useGetIdentity, useRecordContext } from "ra-core";
import { useGetLockLive } from "@react-admin/ra-core-ee";
import { TextInput, SelectInput } from "@components/admin/TextInput";

export const NewMessageForm = () => {
    const [create, { isLoading: isCreating }] = useCreate();
    const record = useRecordContext();

    const { data: lock } = useGetLockLive("tickets", { id: record.id });
    const { identity } = useGetIdentity();
    const isFormDisabled = lock && lock.identity !== identity?.id;

    const [doLock] = useLockOnCall({ resource: "tickets" });
    const handleSubmit = (values) => {
        /* ... */
    };

    return (
        <Form onSubmit={handleSubmit}>
            <TextInput
                source="message"
                multiline
                onFocus={() => {
                    doLock();
                }}
                disabled={isFormDisabled}
            />
            <SelectInput source="status" choices={statusChoices} disabled={isFormDisabled} />
            <button type="submit" disabled={isCreating || isFormDisabled}>
                Submit
            </button>
        </Form>
    );
};

This feature leverages the following hooks:

DataProvider Requirements

To enable real-time features, the dataProvider must implement three new methods:

  • subscribe(topic, callback)
  • unsubscribe(topic, callback)
  • publish(topic, event) (optional - publication is often done server-side)

These methods should return an empty Promise resolved when the action was acknowledged by the real-time bus.

In addition, to support the lock features, the dataProvider must implement 4 more methods:

  • lock(resource, { id, identity, meta })
  • unlock(resource, { id, identity, meta })
  • getLock(resource, { id, meta })
  • getLocks(resource, { meta })

Supabase Adapter

The ra-realtime package contains a function augmenting a regular (API-based) dataProvider with real-time methods based on the capabilities of Supabase.

This adapter subscribes to Postgres Changes, and transforms the events into the format expected by ra-realtime.

import { addRealTimeMethodsBasedOnSupabase, ListLiveUpdate } from '@react-admin/ra-core-ee';
import { supabaseDataProvider } from 'ra-supabase';
import { createClient } from '@supabase/supabase-js';
import { CoreAdmin, Resource, ListBase } from 'ra-core';

const supabaseClient = createClient(
    process.env.SUPABASE_URL,
    process.env.SUPABASE_ANON_KEY
);

const dataProvider = supabaseDataProvider({
    instanceUrl: process.env.SUPABASE_URL,
    apiKey: process.env.SUPABASE_ANON_KEY,
    supabaseClient
});

const realTimeDataProvider = addRealTimeMethodsBasedOnSupabase({
    dataProvider,
    supabaseClient,
});

export const App = () => {
    return (
        <CoreAdmin dataProvider={realTimeDataProvider}>
            <Resource name="sales" list={SaleList} />
        </CoreAdmin>
    );
};

const SaleList = () => (
    <List>
        {/* List view */}
        <ListLiveUpdate />
    </List>
);
import { addRealTimeMethodsBasedOnSupabase, ListLiveUpdate } from "@react-admin/ra-core-ee";
import { supabaseDataProvider } from "ra-supabase";
import { createClient } from "@supabase/supabase-js";
import { CoreAdmin, Resource } from "ra-core";

const supabaseClient = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);

const dataProvider = supabaseDataProvider({
    instanceUrl: process.env.SUPABASE_URL,
    apiKey: process.env.SUPABASE_ANON_KEY,
    supabaseClient,
});

const realTimeDataProvider = addRealTimeMethodsBasedOnSupabase({
    dataProvider,
    supabaseClient,
});

export const App = () => {
    return (
        <CoreAdmin dataProvider={realTimeDataProvider}>
            <Resource name="sales" list={SaleList} />
        </CoreAdmin>
    );
};

const SaleList = () => (
    <List>
        {/* List view */}
        <ListLiveUpdate />
    </List>
);

Tip: Realtime features are not enabled in Supabase by default, you need to enable them. This can be done either from the Replication section of your Supabase Dashboard, or by running the following SQL query with the SQL Editor:

begin;

-- remove the supabase_realtime publication
drop
  publication if exists supabase_realtime;

-- re-create the supabase_realtime publication with no tables
create publication supabase_realtime;

commit;

-- add a table to the publication
alter
  publication supabase_realtime add table sales;
alter
  publication supabase_realtime add table contacts;
alter
  publication supabase_realtime add table contactNotes;

Have a look at the Supabase Replication Setup documentation section for more info.

addRealTimeMethodsBasedOnSupabase accepts the following parameters:

Prop Required Type Default Description
dataProvider Required DataProvider - The base dataProvider to augment with realtime methods
supabaseClient Required SupabaseClient - The Supabase JS Client
Custom Tokens

You may choose to sign your own tokens to customize claims that can be checked in your RLS policies. In order to use these custom tokens with addRealTimeMethodsBasedOnSupabase, you must pass apikey in both Realtime's headers and params when creating the supabaseClient.

Please follow the instructions from the Supabase documentation for more information about how to do so.

API-Platform Adapter

The ra-realtime package contains a function augmenting a regular (API-based) dataProvider with real-time methods based on the capabilities of API-Platform. Use it as follows:

import { ListBase } from 'ra-core';
import {
    HydraAdmin,
    ResourceGuesser,
    FieldGuesser,
    hydraDataProvider,
} from '@api-platform/admin';
import {
    ListLiveUpdate,
    addRealTimeMethodsBasedOnApiPlatform,
} from '@react-admin/ra-core-ee';

const dataProvider = hydraDataProvider({
    entrypoint: 'https://localhost',
});
const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
    // The original dataProvider (should be a hydra data provider passed by API-Platform)
    dataProvider,
    // The API-Platform Mercure Hub URL
    'https://localhost/.well-known/mercure',
    // JWT token to authenticate against the API-Platform Mercure Hub
    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM',
    // The topic URL used by API-Platform (without a slash at the end)
    'https://localhost'
);

const App = () => {
    return (
        <HydraAdmin
            entrypoint="https://localhost"
            dataProvider={realTimeDataProvider}
        >
            <ResourceGuesser name="greetings" list={GreetingsList} />
        </HydraAdmin>
    );
};

// Example for connecting a list of greetings
const GreetingsList = () => (
    <ListBase>
        {/* List view */}
    </ListBase>
);
import { ListBase } from "ra-core";
import { HydraAdmin, ResourceGuesser, hydraDataProvider } from "@api-platform/admin";
import { addRealTimeMethodsBasedOnApiPlatform } from "@react-admin/ra-core-ee";

const dataProvider = hydraDataProvider({
    entrypoint: "https://localhost",
});
const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
    // The original dataProvider (should be a hydra data provider passed by API-Platform)
    dataProvider,
    // The API-Platform Mercure Hub URL
    "https://localhost/.well-known/mercure",
    // JWT token to authenticate against the API-Platform Mercure Hub
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM",
    // The topic URL used by API-Platform (without a slash at the end)
    "https://localhost"
);

const App = () => {
    return (
        <HydraAdmin entrypoint="https://localhost" dataProvider={realTimeDataProvider}>
            <ResourceGuesser name="greetings" list={GreetingsList} />
        </HydraAdmin>
    );
};

// Example for connecting a list of greetings
const GreetingsList = () => <ListBase>{/* List view */}</ListBase>;

The addRealTimeMethodsBasedOnApiPlatform function also accepts an optional 5th argument allowing to customize the transformTopicFromRaRealtime function (responsible for transforming the topic argument from the Admin into a valid Mercure topic for Api Platform).

import { hydraDataProvider } from '@api-platform/admin';
import { addRealTimeMethodsBasedOnApiPlatform } from '@react-admin/ra-realtime';

const dataProvider = hydraDataProvider({
    entrypoint: 'https://localhost',
});

function myTransformTopicFromRaRealtime(topic: string): string {
    const [_basename, _resourcePrefix, resource, ...id] = topic.split('/');
    if (!id || id.length === 0) {
        return `/${resource}/{id}`;
    }
    const originId = id[2];
    return `/${resource}/${originId}`;
}

const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
    dataProvider,
    'https://localhost/.well-known/mercure',
    'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM',
    'https://localhost',
    // Pass the custom transformTopicFromRaRealtime function here
    myTransformTopicFromRaRealtime
);
import { hydraDataProvider } from "@api-platform/admin";
import { addRealTimeMethodsBasedOnApiPlatform } from "@react-admin/ra-realtime";

const dataProvider = hydraDataProvider({
    entrypoint: "https://localhost",
});

function myTransformTopicFromRaRealtime(topic) {
    const [_basename, _resourcePrefix, resource, ...id] = topic.split("/");
    if (!id || id.length === 0) {
        return `/${resource}/{id}`;
    }
    const originId = id[2];
    return `/${resource}/${originId}`;
}

const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
    dataProvider,
    "https://localhost/.well-known/mercure",
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM",
    "https://localhost",
    // Pass the custom transformTopicFromRaRealtime function here
    myTransformTopicFromRaRealtime
);

Mercure Adapter

The ra-realtime package contains a function augmenting a regular (API-based) dataProvider with real-time methods based on a Mercure hub. Use it as follows:

import { addRealTimeMethodsBasedOnMercure } from '@react-admin/ra-core-ee';
import { CoreAdmin } from 'ra-core';

const realTimeDataProvider = addRealTimeMethodsBasedOnMercure(
    // original dataProvider
    dataProvider,
    // Mercure hub URL
    'http://path.to.my.api/.well-known/mercure',
    // JWT token to authenticate against the Mercure Hub
    'eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOlsiKiJdfX0.SWKHNF9wneXTSjBg81YN5iH8Xb2iTf_JwhfUY5Iyhsw'
);

const App = () => (
    <CoreAdmin dataProvider={realTimeDataProvider}>{/* ... */}</CoreAdmin>
);
import { addRealTimeMethodsBasedOnMercure } from "@react-admin/ra-core-ee";
import { CoreAdmin } from "ra-core";

const realTimeDataProvider = addRealTimeMethodsBasedOnMercure(
    // original dataProvider
    dataProvider,
    // Mercure hub URL
    "http://path.to.my.api/.well-known/mercure",
    // JWT token to authenticate against the Mercure Hub
    "eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOlsiKiJdfX0.SWKHNF9wneXTSjBg81YN5iH8Xb2iTf_JwhfUY5Iyhsw"
);

const App = () => <CoreAdmin dataProvider={realTimeDataProvider}>{/* ... */}</CoreAdmin>;

Writing a Custom Adapter

If you're using another transport for real-time messages (WebSockets, long polling, GraphQL subscriptions, etc.), you'll have to implement subscribe, unsubscribe, and publish yourself in your dataProvider. As an example, here is an implementation using a local variable, that ra-realtime uses in tests:

let subscriptions = [];

const dataProvider = {
    // regular dataProvider methods like getList, getOne, etc,
    // ...
    subscribe: async (topic, subscriptionCallback) => {
        subscriptions.push({ topic, subscriptionCallback });
        return Promise.resolve({ data: null });
    },

    unsubscribe: async (topic, subscriptionCallback) => {
        subscriptions = subscriptions.filter(
            subscription =>
                subscription.topic !== topic ||
                subscription.subscriptionCallback !== subscriptionCallback
        );
        return Promise.resolve({ data: null });
    },

    publish: (topic, event) => {
        if (!topic) {
            return Promise.reject(new Error('missing topic'));
        }
        if (!event.type) {
            return Promise.reject(new Error('missing event type'));
        }
        subscriptions.map(
            subscription =>
                topic === subscription.topic &&
                subscription.subscriptionCallback(event)
        );
        return Promise.resolve({ data: null });
    },
};
let subscriptions = [];

const dataProvider = {
    // regular dataProvider methods like getList, getOne, etc,
    // ...
    subscribe: async (topic, subscriptionCallback) => {
        subscriptions.push({ topic, subscriptionCallback });
        return Promise.resolve({ data: null });
    },

    unsubscribe: async (topic, subscriptionCallback) => {
        subscriptions = subscriptions.filter(
            (subscription) => subscription.topic !== topic || subscription.subscriptionCallback !== subscriptionCallback
        );
        return Promise.resolve({ data: null });
    },

    publish: (topic, event) => {
        if (!topic) {
            return Promise.reject(new Error("missing topic"));
        }
        if (!event.type) {
            return Promise.reject(new Error("missing event type"));
        }
        subscriptions.map((subscription) => topic === subscription.topic && subscription.subscriptionCallback(event));
        return Promise.resolve({ data: null });
    },
};

You can check the behavior of the real-time components by using the default console logging provided in addRealTimeMethodsInLocalBrowser.

Topic And Event Format

You've noticed that all the dataProvider real-time methods expect a topic as the first argument. A topic is just a string, identifying a particular real-time channel. Topics can be used e.g. to dispatch messages to different rooms in a chat application or to identify changes related to a particular record.

Most ra-realtime components deal with CRUD logic, so ra-realtime subscribes to special topics named resource/[name] and resource/[name]/[id]. For your own events, use any topic you want.

The event is the name of the message sent from publishers to subscribers. An event should be a JavaScript object with a type and a payload field.

Here is an example event:

{
    type: 'created',
    payload: 'New message',
}

For CRUD operations, ra-realtime expects events to use the types 'created', 'updated', and 'deleted'.

CRUD Events

Ra-realtime has deep integration with react-admin, where most of the logic concerns Creation, Update or Deletion (CRUD) of records. To enable this integration, your real-time backend should publish the following events:

  • when a new record is created:
{
    topic: `resource/${resource}`,
    event: {
        type: 'created',
        payload: { ids: [id]},
    },
}
  • when a record is updated:
{
    topic: `resource/${resource}/id`,
    event: {
        type: 'updated',
        payload: { ids: [id]},
    },
}
{
    topic: `resource/${resource}`,
    event: {
        type: 'updated',
        payload: { ids: [id]},
    },
}
  • when a record is deleted:
{
    topic: `resource/${resource}/id`,
    event: {
        type: 'deleted',
        payload: { ids: [id]},
    },
}
{
    topic: `resource/${resource}`,
    event: {
        type: 'deleted',
        payload: { ids: [id]},
    },
}

Lock Format

A lock stores the record that is locked, the identity of the locker, and the time at which the lock was acquired. It is used to prevent concurrent editing of the same record. A typical lock looks like this:

{
    resource: 'posts',
    recordId: 123,
    identity: 'julien',
    createdAt: '2023-01-02T21:36:35.133Z',
}

The dataProvider.getLock() and dataProvider.getLocks() methods should return these locks.

As for the mutation methods (dataProvider.lock(), dataProvider.unlock()), they expect the following parameters:

  • resource: the resource name (e.g. 'posts')
  • params: an object containing the following
    • id: the record id (e.g. 123)
    • identity: an identifier (string or number) corresponding to the identity of the locker (e.g. 'julien'). This could be an authentication token for instance.
    • meta: an object that will be forwarded to the dataProvider (optional)

Locks Based On A Lock Resource

The ra-realtime package offers a function augmenting a regular (API-based) dataProvider with locks methods based on a locks resource.

It will translate a dataProvider.getLocks() call to a dataProvider.getList('locks') call, and a dataProvider.lock() call to a dataProvider.create('locks') call.

The lock resource should contain the following fields:

{
    "id": 123,
    "identity": "Toad",
    "resource": "people",
    "recordId": 18,
    "createdAt": "2020-09-29 10:20"
}

Please note that the identity and the createdAt formats depend on your API.

Here is how to use it in your react-admin application:

import { CoreAdmin } from 'ra-core';
import { addLocksMethodsBasedOnALockResource } from '@react-admin/ra-core-ee';

const dataProviderWithLocks = addLocksMethodsBasedOnALockResource(
    dataProvider // original dataProvider
);

const App = () => (
    <CoreAdmin dataProvider={dataProviderWithLocks}>{/* ... */}</CoreAdmin>
);
import { CoreAdmin } from "ra-core";
import { addLocksMethodsBasedOnALockResource } from "@react-admin/ra-core-ee";

const dataProviderWithLocks = addLocksMethodsBasedOnALockResource(
    dataProvider // original dataProvider
);

const App = () => <CoreAdmin dataProvider={dataProviderWithLocks}>{/* ... */}</CoreAdmin>;

Calling the dataProvider Methods Directly

Once you've set a real-time dataProvider, you can call the real-time methods in your React components via the useDataProvider hook.

For instance, here is a component displaying messages posted to the 'messages' topic in real time:

import React, { useState } from 'react';
import { useDataProvider, useNotify } from 'ra-core';

const MessageList = () => {
    const notify = useNotify();
    const [messages, setMessages] = useState([]);
    const dataProvider = useDataProvider();

    useEffect(() => {
        const callback = event => {
            // event is like
            // {
            //     topic: 'messages',
            //     type: 'created',
            //     payload: 'New message',
            // }
            setMessages(messages => [...messages, event.payload]);
            notify('New message');
        };
        // subscribe to the 'messages' topic on mount
        dataProvider.subscribe('messages', callback);
        // unsubscribe on unmount
        return () => dataProvider.unsubscribe('messages', callback);
    }, [setMessages, notify, dataProvider]);

    return (
        <ul>
            {messages.map((message, index) => (
                <li key={index}>{message}</li>
            ))}
        </ul>
    );
};
import React, { useState } from "react";
import { useDataProvider, useNotify } from "ra-core";

const MessageList = () => {
    const notify = useNotify();
    const [messages, setMessages] = useState([]);
    const dataProvider = useDataProvider();

    useEffect(() => {
        const callback = (event) => {
            // event is like
            // {
            //     topic: 'messages',
            //     type: 'created',
            //     payload: 'New message',
            // }
            setMessages((messages) => [...messages, event.payload]);
            notify("New message");
        };
        // subscribe to the 'messages' topic on mount
        dataProvider.subscribe("messages", callback);
        // unsubscribe on unmount
        return () => dataProvider.unsubscribe("messages", callback);
    }, [setMessages, notify, dataProvider]);

    return (
        <ul>
            {messages.map((message, index) => (
                <li key={index}>{message}</li>
            ))}
        </ul>
    );
};

And here is a button for publishing an event to the messages topic. All the subscribers to this topic will execute their callback:

import React from 'react';
import { useDataProvider, useNotify } from 'ra-core';

const SendMessageButton = () => {
    const dataProvider = useDataProvider();
    const notify = useNotify();
    const handleClick = () => {
        dataProvider
            .publish('messages', { type: 'created', payload: 'New message' })
            .then(() => notify('Message sent'));
    };

    return <button onClick={handleClick}>Send new message</button>;
};
import React from "react";
import { useDataProvider, useNotify } from "ra-core";

const SendMessageButton = () => {
    const dataProvider = useDataProvider();
    const notify = useNotify();
    const handleClick = () => {
        dataProvider
            .publish("messages", { type: "created", payload: "New message" })
            .then(() => notify("Message sent"));
    };

    return <button onClick={handleClick}>Send new message</button>;
};

Tip: You should not need to call publish() directly very often. Most real-time backends publish events in reaction to a change in the data. So the previous example is fictive. In reality, a typical <SendMessageButton> would simply call dataProvider.create('messages'), and the API would create the new message AND publish the 'created' event to the real-time bus.

Hooks

usePublish

Get a callback to publish an event on a topic. The callback returns a promise that resolves when the event is published.

usePublish calls dataProvider.publish() to publish the event. It leverages react-query's useMutation hook to provide a callback.

Note: Events should generally be published by the server, in reaction to an action by an end user. They should seldom be published directly by the client. This hook is provided mostly for testing purposes, but you may use it in your own custom components if you know what you're doing.

Usage

usePublish returns a callback with the following signature:

const publish = usePublish();
publish(topic, event, options);
const publish = usePublish();
publish(topic, event, options);

For instance, in a chat application, when a user is typing a message, the following component publishes a typing event to the chat/[channel] topic:

import { useInput, useGetIdentity } from 'ra-core';
import { usePublish } from '@react-admin/ra-core-ee';

const MessageInput = ({ channel }) => {
    const [publish, { isLoading }] = usePublish();
    const { id, field, fieldState } = useInput({ source: 'message' });
    const { identity } = useGetIdentity();

    const handleUserInput = event => {
        publish(`chat/${channel}`, {
            type: 'typing',
            payload: { user: identity },
        });
    };

    return (
        <label htmlFor={id}>
            Type your message
            <input id={id} {...field} onInput={handleUserInput} />
        </label>
    );
};
import { useInput, useGetIdentity } from "ra-core";
import { usePublish } from "@react-admin/ra-core-ee";

const MessageInput = ({ channel }) => {
    const [publish, { isLoading }] = usePublish();
    const { id, field, fieldState } = useInput({ source: "message" });
    const { identity } = useGetIdentity();

    const handleUserInput = (event) => {
        publish(`chat/${channel}`, {
            type: "typing",
            payload: { user: identity },
        });
    };

    return (
        <label htmlFor={id}>
            Type your message
            <input id={id} {...field} onInput={handleUserInput} />
        </label>
    );
};

The event format is up to you. It should at least contain a type property and may contain a payload property. The payload property can contain any data you want to send to the subscribers.

Some hooks and components in this package are specialized to handle "CRUD" events, which are events with a type property set to created, updated or deleted. For instance:

{
    topic: `resource/${resource}/id`,
    event: {
        type: 'deleted',
        payload: { ids: [id]},
    },
}

See the CRUD events section for more details.

Return Value

usePublish returns an array with the following values:

  • publish: The callback to publish an event to a topic.
  • state: The state of the mutation (see react-query documentation). Notable properties:
    • isLoading: Whether the mutation is loading.
    • error: The error if the mutation failed.
    • data: The published event if the mutation succeeded.
const [publish, { isLoading, error, data }] = usePublish();
const [publish, { isLoading, error, data }] = usePublish();
Callback Parameters

The publish callback accepts the following parameters:

  • topic: The topic to publish the event on.
  • event: The event to publish. It must contain a type property.
  • options: useMutation options (see react-query documentation). Notable properties:
    • onSuccess: A callback to call when the event is published. It receives the published event as its first argument.
    • onError: A callback to call when the event could not be published. It receives the error as its first argument.
    • retry: Whether to retry on failure. Defaults to 0.
const [publish] = usePublish();
publish(
    'chat/general',
    {
        type: 'message',
        payload: { user: 'John', message: 'Hello!' },
    },
    {
        onSuccess: event => console.log('Event published', event),
        onError: error => console.log('Could not publish event', error),
        retry: 3,
    }
);
const [publish] = usePublish();
publish(
    "chat/general",
    {
        type: "message",
        payload: { user: "John", message: "Hello!" },
    },
    {
        onSuccess: (event) => console.log("Event published", event),
        onError: (error) => console.log("Could not publish event", error),
        retry: 3,
    }
);

useSubscribe

Subscribe to the events from a topic on mount (and unsubscribe on unmount).

Usage

The following component subscribes to the messages/{channelName} topic and displays a badge with the number of unread messages:

import { useState, useCallback } from 'react';
import { useSubscribe } from '@react-admin/ra-core-ee';

const ChannelName = ({ name }) => {
    const [nbMessages, setNbMessages] = useState(0);

    const callback = useCallback(
        event => {
            if (event.type === 'created') {
                setNbMessages(count => count + 1);
            }
        },
        [setNbMessages]
    );

    useSubscribe(`messages/${name}`, callback);

    return nbMessages > 0 ? (
        <p>#{name} ({nbMessages} new messages)</p>
    ) : (
        <p>#{name}</p>
    );
};
import { useState, useCallback } from "react";
import { useSubscribe } from "@react-admin/ra-core-ee";

const ChannelName = ({ name }) => {
    const [nbMessages, setNbMessages] = useState(0);

    const callback = useCallback(
        (event) => {
            if (event.type === "created") {
                setNbMessages((count) => count + 1);
            }
        },
        [setNbMessages]
    );

    useSubscribe(`messages/${name}`, callback);

    return nbMessages > 0 ? (
        <p>
            #{name} ({nbMessages} new messages)
        </p>
    ) : (
        <p>#{name}</p>
    );
};
Parameters
Prop Required Type Default Description
topic Optional string - The topic to subscribe to. When empty, no subscription is created.
callback Optional function - The callback to execute when an event is received.
options Optional object - Options to modify the subscription / unsubscription behavior.
callback

This function will be called with the event as its first argument, so you can use it to update the UI.

useSubscribe(`messages/${name}`, event => {
    if (event.type === 'created') {
        setNbMessages(count => count + 1);
    }
});
useSubscribe(`messages/${name}`, (event) => {
    if (event.type === "created") {
        setNbMessages((count) => count + 1);
    }
});

Tip: Memoize the callback using useCallback to avoid unnecessary subscriptions/unsubscriptions.

const callback = useCallback(
    event => {
        if (event.type === 'created') {
            setNbMessages(count => count + 1);
        }
    },
    [setNbMessages]
);
useSubscribe(`messages/${name}`, callback);
const callback = useCallback(
    (event) => {
        if (event.type === "created") {
            setNbMessages((count) => count + 1);
        }
    },
    [setNbMessages]
);
useSubscribe(`messages/${name}`, callback);

The callback function receives an unsubscribe callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.

import { useState, useCallback } from 'react';
import { useSubscribe } from '@react-admin/ra-core-ee';

const JobProgress = ({ jobId }) => {
    const [progress, setProgress] = useState(0);
    const callback = useCallback(
        (event, unsubscribe) => {
            if (event.type === 'progress') {
                setProgress(event.payload.progress);
            }
            if (event.type === 'completed') {
                unsubscribe();
            }
        },
        [setColor]
    );
    useSubscribe(`jobs/${jobId}`, callback);
    return (
        <div>{progress}%</div>
    );
};
import { useState, useCallback } from "react";
import { useSubscribe } from "@react-admin/ra-core-ee";

const JobProgress = ({ jobId }) => {
    const [progress, setProgress] = useState(0);
    const callback = useCallback(
        (event, unsubscribe) => {
            if (event.type === "progress") {
                setProgress(event.payload.progress);
            }
            if (event.type === "completed") {
                unsubscribe();
            }
        },
        [setColor]
    );
    useSubscribe(`jobs/${jobId}`, callback);
    return <div>{progress}%</div>;
};
options

The options object can contain the following properties:

  • enabled: Whether to subscribe or not. Defaults to true
  • once: Whether to unsubscribe after the first event. Defaults to false.
  • unsubscribeOnUnmount: Whether to unsubscribe on unmount. Defaults to true.

You can use the once option to subscribe to a topic only once, and then unsubscribe.

For instance, the following component subscribes to the office/restart topic and changes the message when the office is open, then unsubscribes from the topic:

import { useState } from 'react';
import { useSubscribe } from '@react-admin/ra-core-ee';

const OfficeClosed = () => {
    const [state, setState] = useState('closed');

    useSubscribe('office/restart', () => setState('open'), { once: true });

    return (
        <div>
            {state === 'closed'
                ? 'Sorry, the office is closed for maintenance.'
                : 'Welcome! The office is open.'}
        </div>
    );
};
import { useState } from "react";
import { useSubscribe } from "@react-admin/ra-core-ee";

const OfficeClosed = () => {
    const [state, setState] = useState("closed");

    useSubscribe("office/restart", () => setState("open"), { once: true });

    return (
        <div>
            {state === "closed" ? "Sorry, the office is closed for maintenance." : "Welcome! The office is open."}
        </div>
    );
};
topic

The first argument of useSubscribe is the topic to subscribe to. It can be an arbitrary string.

useSubscribe('messages', event => {
    // ...
});
useSubscribe("messages", (event) => {
    // ...
});

If you want to subscribe to CRUD events, instead of writing the topic manually like resource/[resource], you can use the useSubscribeToRecord or useSubscribeToRecordList hooks.

useSubscribeCallback

Get a callback to subscribe to events on a topic and optionally unsubscribe on unmount.

This is useful to start a subscription from an event handler, like a button click.

Usage

The following component subscribes to the backgroundJobs/recompute topic on click, and displays the progress of the background job:

import { useState, useCallback } from 'react';
import { useDataProvider } from 'ra-core';
import { useSubscribeCallback } from '@react-admin/ra-core-ee';

const LaunchBackgroundJob = () => {
    const dataProvider = useDataProvider();
    const [progress, setProgress] = useState(0);
    const callback = useCallback(
        (event, unsubscribe) => {
            setProgress(event.payload?.progress || 0);
            if (event.payload?.progress === 100) {
                unsubscribe();
            }
        },
        [setProgress]
    );
    const subscribe = useSubscribeCallback(
        'backgroundJobs/recompute',
        callback
    );

    return (
        <div>
            <button
                onClick={() => {
                    subscribe();
                    dataProvider.recompute();
                }}
            >
                {progress === 0 ? 'Launch recompute' : `Recompute in progress (${progress}%)`}
            </button>
        </div>
    );
};
import { useState, useCallback } from "react";
import { useDataProvider } from "ra-core";
import { useSubscribeCallback } from "@react-admin/ra-core-ee";

const LaunchBackgroundJob = () => {
    const dataProvider = useDataProvider();
    const [progress, setProgress] = useState(0);
    const callback = useCallback(
        (event, unsubscribe) => {
            setProgress(event.payload?.progress || 0);
            if (event.payload?.progress === 100) {
                unsubscribe();
            }
        },
        [setProgress]
    );
    const subscribe = useSubscribeCallback("backgroundJobs/recompute", callback);

    return (
        <div>
            <button
                onClick={() => {
                    subscribe();
                    dataProvider.recompute();
                }}
            >
                {progress === 0 ? "Launch recompute" : `Recompute in progress (${progress}%)`}
            </button>
        </div>
    );
};
Parameters
Prop Required Type Default Description
topic Optional string - The topic to subscribe to. When empty, no subscription is created.
callback Optional function - The callback to execute when an event is received.
options Optional object - Options to modify the subscription / unsubscription behavior.
callback

Whenever an event is published on the topic passed as the first argument, the function passed as the second argument will be called with the event as a parameter.

const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => {
    if (event.type === 'progress') {
        setProgress(event.payload.progress);
    }
});

// later
subscribe();
const subscribe = useSubscribeCallback("backgroundJobs/recompute", (event) => {
    if (event.type === "progress") {
        setProgress(event.payload.progress);
    }
});

// later
subscribe();

Tip: Memoize the callback using useCallback to avoid unnecessary subscriptions/unsubscriptions.

const callback = useCallback(
    event => {
        if (event.type === 'progress') {
            setProgress(event.payload.progress);
        }
    },
    [setProgress]
);
const callback = useCallback(
    (event) => {
        if (event.type === "progress") {
            setProgress(event.payload.progress);
        }
    },
    [setProgress]
);

The callback function receives an unsubscribe callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.

const subscribe = useSubscribeCallback(
    'backgroundJobs/recompute',
    (event, unsubscribe) => {
        if (event.type === 'completed') {
            setProgress(100);
            unsubscribe();
        }
    }
);
const subscribe = useSubscribeCallback("backgroundJobs/recompute", (event, unsubscribe) => {
    if (event.type === "completed") {
        setProgress(100);
        unsubscribe();
    }
});
options

The options object can contain the following properties:

  • enabled: Whether to subscribe or not. Defaults to true
  • once: Whether to unsubscribe after the first event. Defaults to false.
  • unsubscribeOnUnmount: Whether to unsubscribe on unmount. Defaults to true.

You can use the once option to subscribe to a topic only once, and then unsubscribe.

For instance, the following component subscribes to the backgroundJobs/recompute topic on click, displays a notification when the background job is complete, then unsubscribes:

import { useDataProvider, useNotify } from 'ra-core';
import { useSubscribeCallback } from '@react-admin/ra-core-ee';

const LaunchBackgroundJob = () => {
    const dataProvider = useDataProvider();
    const notify = useNotify();

    const subscribe = useSubscribeCallback(
        'backgroundJobs/recompute',
        event =>
            notify('Recompute complete: %{summary}', {
                type: 'success',
                messageArgs: {
                    summary: event.payload?.summary,
                },
            }),
        {
            unsubscribeOnUnmount: false, // show the notification even if the user navigates away
            once: true, // unsubscribe after the first event
        }
    );

    return (
        <button
            onClick={() => {
                subscribe();
                dataProvider.recompute();
            }}
        >
            Launch background job
        </button>
    );
};

You can use the unsubscribeOnUnmount option to keep the subscription alive after the component unmounts.

This can be useful when you want the subscription to persist across multiple pages.

const subscribe = useSubscribeCallback(
    'backgroundJobs/recompute',
    event => setProgress(event.payload?.progress || 0),
    {
        unsubscribeOnUnmount: false, // don't unsubscribe on unmount
    }
);
const subscribe = useSubscribeCallback(
    "backgroundJobs/recompute",
    (event) => setProgress(event.payload?.progress || 0),
    {
        unsubscribeOnUnmount: false, // don't unsubscribe on unmount
    }
);
topic

The first argument of useSubscribeCallback is the topic to subscribe to. It can be an arbitrary string.

const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => {
    // ...
});

// later
subscribe();
const subscribe = useSubscribeCallback("backgroundJobs/recompute", (event) => {
    // ...
});

// later
subscribe();

useSubscribeToRecord

This specialized version of useSubscribe subscribes to events concerning a single record.

Usage

The hook expects a callback function as its only argument, as it guesses the record and resource from the current context. The callback will be executed whenever an event is published on the resource/[resource]/[recordId] topic.

For instance, the following component displays a message when the record is updated by someone else:

const WarnWhenUpdatedBySomeoneElse = () => {
    const [open, setOpen] = useState(false);
    const [author, setAuthor] = useState<string | null>(null);
    const handleClose = () => {
        setOpen(false);
    };
    const { refetch } = useEditContext();
    const refresh = () => {
        refetch();
        handleClose();
    };
    const {
        formState: { isDirty },
    } = useFormContext();

    useSubscribeToRecord((event: Event) => {
        if (event.type === 'edited') {
            if (isDirty) {
                setOpen(true);
                setAuthor(event.payload.user);
            } else {
                refetch();
            }
        }
    });
    return open ? (
        <div className="flex flex-col gap-4">
            <p>
                Post Updated by {author}
            </p>
            <p>
                Your changes and their changes may conflict. What do you
                want to do?
            </p>
            <div className="flex gap-4">
                <button onClick={handleClose}>Keep my changes</button>
                <button onClick={refresh}>
                    Get their changes (and lose mine)
                </button>
            </div>
        </div>
    ) : null;
};

const PostEdit = () => (
    <EditBase>
        <Form>
            {/* Inputs... */}
            <WarnWhenUpdatedBySomeoneElse />
        </Form>
    </EditBase>
);
const WarnWhenUpdatedBySomeoneElse = () => {
    const [open, setOpen] = useState(false);
    const [author, setAuthor] = useState(null);
    const handleClose = () => {
        setOpen(false);
    };
    const { refetch } = useEditContext();
    const refresh = () => {
        refetch();
        handleClose();
    };
    const {
        formState: { isDirty },
    } = useFormContext();

    useSubscribeToRecord((event) => {
        if (event.type === "edited") {
            if (isDirty) {
                setOpen(true);
                setAuthor(event.payload.user);
            } else {
                refetch();
            }
        }
    });
    return open ? (
        <div className="flex flex-col gap-4">
            <p>Post Updated by {author}</p>
            <p>Your changes and their changes may conflict. What do you want to do?</p>
            <div className="flex gap-4">
                <button onClick={handleClose}>Keep my changes</button>
                <button onClick={refresh}>Get their changes (and lose mine)</button>
            </div>
        </div>
    ) : null;
};

const PostEdit = () => (
    <EditBase>
        <Form>
            {/* Inputs... */}
            <WarnWhenUpdatedBySomeoneElse />
        </Form>
    </EditBase>
);

useSubscribeToRecord reads the current resource and record from the ResourceContext and RecordContext respectively. In the example above, the notification is displayed when the app receives an event on the resource/books/123 topic.

Just like useSubscribe, useSubscribeToRecord unsubscribes from the topic when the component unmounts.

Tip: In the example above, <Show> creates the RecordContext- that's why the useSubscribeToRecord hook is used in its child component instead of in the <BookShow> component.

You can provide the resource and record id explicitly if you are not in such contexts:

useSubscribeToRecord(
    event => {
        /* ... */
    },
    'posts',
    123
);
useSubscribeToRecord(
    (event) => {
        /* ... */
    },
    "posts",
    123
);

Tip: If your reason to subscribe to events on a record is to keep the record up to date, you should use the useGetOneLive hook instead.

Parameters
Prop Required Type Default Description
callback Required function - The callback to execute when an event is received.
resource Optional string - The resource to subscribe to. Defaults to the resource in the ResourceContext.
recordId Optional string - The record id to subscribe to. Defaults to the id of the record in the RecordContext.
options Optional object - The subscription options.
callback

Whenever an event is published on the resource/[resource]/[recordId] topic, the function passed as the first argument will be called with the event as a parameter.

const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const { refetch } = useEditContext();
const {
    formState: { isDirty },
} = useFormContext();
useSubscribeToRecord((event: Event) => {
    if (event.type === 'edited') {
        if (isDirty) {
            setOpen(true);
            setAuthor(event.payload.user);
        } else {
            refetch();
        }
    }
});
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState(null);
const { refetch } = useEditContext();
const {
    formState: { isDirty },
} = useFormContext();
useSubscribeToRecord((event) => {
    if (event.type === "edited") {
        if (isDirty) {
            setOpen(true);
            setAuthor(event.payload.user);
        } else {
            refetch();
        }
    }
});

Tip: Memoize the callback using useCallback to avoid unnecessary subscriptions/unsubscriptions.

const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const { refetch } = useEditContext();
const {
    formState: { isDirty },
} = useFormContext();

const handleEvent = useCallback(
    (event: Event) => {
        if (event.type === 'edited') {
            if (isDirty) {
                setOpen(true);
                setAuthor(event.payload.user);
            } else {
                refetch();
            }
        }
    },
    [isDirty, refetch, setOpen, setAuthor]
);

useSubscribeToRecord(handleEvent);
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState(null);
const { refetch } = useEditContext();
const {
    formState: { isDirty },
} = useFormContext();

const handleEvent = useCallback(
    (event) => {
        if (event.type === "edited") {
            if (isDirty) {
                setOpen(true);
                setAuthor(event.payload.user);
            } else {
                refetch();
            }
        }
    },
    [isDirty, refetch, setOpen, setAuthor]
);

useSubscribeToRecord(handleEvent);

Just like for useSubscribe, the callback function receives an unsubscribe callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.

useSubscribeToRecord((event: Event, unsubscribe) => {
    if (event.type === 'deleted') {
        // do something
        unsubscribe();
    }
    if (event.type === 'edited') {
        if (isDirty) {
            setOpen(true);
            setAuthor(event.payload.user);
        } else {
            refetch();
        }
    }
});
useSubscribeToRecord((event, unsubscribe) => {
    if (event.type === "deleted") {
        // do something
        unsubscribe();
    }
    if (event.type === "edited") {
        if (isDirty) {
            setOpen(true);
            setAuthor(event.payload.user);
        } else {
            refetch();
        }
    }
});
options

The options object can contain the following properties:

  • enabled: Whether to subscribe or not. Defaults to true
  • once: Whether to unsubscribe after the first event. Defaults to false.
  • unsubscribeOnUnmount: Whether to unsubscribe on unmount. Defaults to true.

See useSubscribe for more details.

recordId

The record id to subscribe to. By default, useSubscribeToRecord builds the topic it subscribes to using the id of the record in the RecordContext. But you can override this behavior by passing a record id as the third argument.

// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
    event => {
        /* ... */
    },
    'posts',
    123
);
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
    (event) => {
        /* ... */
    },
    "posts",
    123
);

Note that if you pass a null record id, the hook will not subscribe to any topic.

resource

The resource to subscribe to. By default, useSubscribeToRecord builds the topic it subscribes to using the resource in the ResourceContext. But you can override this behavior by passing a resource name as the second argument.

// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
    event => {
        /* ... */
    },
    'posts',
    123
);
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
    (event) => {
        /* ... */
    },
    "posts",
    123
);

Note that if you pass an empty string as the resource name, the hook will not subscribe to any topic.

useSubscribeToRecordList

This specialized version of useSubscribe subscribes to events concerning a list of records.

Usage

useSubscribeToRecordList expects a callback function as its first argument. It will be executed whenever an event is published on the resource/[resource] topic.

For instance, the following component displays notifications when a record is created, updated, or deleted by someone else:

import React from 'react';
import { useNotify, useListContext } from 'ra-core';
import { useSubscribeToRecordList } from '@react-admin/ra-core-ee';

const ListWatcher = () => {
    const notity = useNotify();
    const { refetch, data } = useListContext();
    useSubscribeToRecordList(event => {
        switch (event.type) {
            case 'created': {
                notity('New movie created');
                refetch();
                break;
            }
            case 'updated': {
                if (data.find(record => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} updated`);
                    refetch();
                }
                break;
            }
            case 'deleted': {
                if (data.find(record => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} deleted`);
                    refetch();
                }
                break;
            }
        }
    });
    return null;
};

const MovieList = () => (
    <ListBase>
        {/* The list view*/}
        <ListWatcher />
    </ListBase>
);
import React from "react";
import { useNotify, useListContext } from "ra-core";
import { useSubscribeToRecordList } from "@react-admin/ra-core-ee";

const ListWatcher = () => {
    const notity = useNotify();
    const { refetch, data } = useListContext();
    useSubscribeToRecordList((event) => {
        switch (event.type) {
            case "created": {
                notity("New movie created");
                refetch();
                break;
            }
            case "updated": {
                if (data.find((record) => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} updated`);
                    refetch();
                }
                break;
            }
            case "deleted": {
                if (data.find((record) => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} deleted`);
                    refetch();
                }
                break;
            }
        }
    });
    return null;
};

const MovieList = () => (
    <ListBase>
        {/* The list view*/}
        <ListWatcher />
    </ListBase>
);
Parameters
Prop Required Type Default Description
callback Required function - The callback function to execute when an event is published on the topic.
resource Optional string - The resource to subscribe to. Defaults to the resource in the ResourceContext.
options Optional object - The subscription options.
callback

Whenever an event is published on the resource/[resource] topic, the function passed as the first argument will be called with the event as a parameter.

const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList(event => {
    switch (event.type) {
        case 'created': {
            notity('New movie created');
            refetch();
            break;
        }
        case 'updated': {
            if (data.find(record => record.id === event.payload.ids[0])) {
                notity(`Movie #${event.payload.ids[0]} updated`);
                refetch();
            }
            break;
        }
        case 'deleted': {
            if (data.find(record => record.id === event.payload.ids[0])) {
                notity(`Movie #${event.payload.ids[0]} deleted`);
                refetch();
            }
            break;
        }
    }
});
const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList((event) => {
    switch (event.type) {
        case "created": {
            notity("New movie created");
            refetch();
            break;
        }
        case "updated": {
            if (data.find((record) => record.id === event.payload.ids[0])) {
                notity(`Movie #${event.payload.ids[0]} updated`);
                refetch();
            }
            break;
        }
        case "deleted": {
            if (data.find((record) => record.id === event.payload.ids[0])) {
                notity(`Movie #${event.payload.ids[0]} deleted`);
                refetch();
            }
            break;
        }
    }
});

Tip: Memoize the callback using useCallback to avoid unnecessary subscriptions/unsubscriptions.

const notity = useNotify();
const { refetch, data } = useListContext();
const callback = useCallback(
    event => {
        switch (event.type) {
            case 'created': {
                notity('New movie created');
                refetch();
                break;
            }
            case 'updated': {
                if (data.find(record => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} updated`);
                    refetch();
                }
                break;
            }
            case 'deleted': {
                if (data.find(record => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} deleted`);
                    refetch();
                }
                break;
            }
        }
    },
    [data, refetch, notity]
);
useSubscribeToRecordList(callback);
const notity = useNotify();
const { refetch, data } = useListContext();
const callback = useCallback(
    (event) => {
        switch (event.type) {
            case "created": {
                notity("New movie created");
                refetch();
                break;
            }
            case "updated": {
                if (data.find((record) => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} updated`);
                    refetch();
                }
                break;
            }
            case "deleted": {
                if (data.find((record) => record.id === event.payload.ids[0])) {
                    notity(`Movie #${event.payload.ids[0]} deleted`);
                    refetch();
                }
                break;
            }
        }
    },
    [data, refetch, notity]
);
useSubscribeToRecordList(callback);

Just like for useSubscribe, the callback function receives an unsubscribe callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.

options

The options object can contain the following properties:

  • enabled: Whether to subscribe or not. Defaults to true
  • once: Whether to unsubscribe after the first event. Defaults to false.
  • unsubscribeOnUnmount: Whether to unsubscribe on unmount. Defaults to true.

See useSubscribe for more details.

resource

useSubscribeToRecordList reads the current resource from the ResourceContext. You can provide the resource explicitly if you are not in such a context:

useSubscribeToRecordList(event => {
    if (event.type === 'updated') {
        notify('Post updated');
        refresh();
    }
}, 'posts');
useSubscribeToRecordList((event) => {
    if (event.type === "updated") {
        notify("Post updated");
        refresh();
    }
}, "posts");

useLock

useLock is a low-level hook that returns a callback to call dataProvider.lock(), leveraging react-query's useMutation.

const [lock, { isLoading, error }] = useLock(
    resource,
    { id, identity, meta },
    options
);
const [lock, { isLoading, error }] = useLock(resource, { id, identity, meta }, options);

The payload is an object with the following properties:

  • id: the record id (e.g. 123)
  • identity: an identifier (string or number) corresponding to the identity of the locker (e.g. 'julien'). This usually comes from authProvider.getIdentity().
  • meta: an object that will be forwarded to the dataProvider (optional)

The optional options argument is passed to react-query's useMutation hook.

For most use cases, you won't need to call the useLock hook directly. Instead, you should use the useLockOnMount or useLockOnCall orchestration hooks, which are responsible for calling useLock and useUnlock.

useUnlock

useUnlock is a low-level hook that returns a callback to call dataProvider.unlock(), leveraging react-query's useMutation.

const [unlock, { isLoading, error }] = useUnlock(
    resource,
    { id, identity, meta },
    options
);
const [unlock, { isLoading, error }] = useUnlock(resource, { id, identity, meta }, options);

The payload is an object with the following properties:

  • id: the record id (e.g. 123)
  • identity: an identifier (string or number) corresponding to the identity of the locker (e.g. 'julien'). This usually comes from authProvider.getIdentity()
  • meta: an object that will be forwarded to the dataProvider (optional)

The optional options argument is passed to react-query's useMutation hook.

useGetLock

Gets the lock status for a record. It calls dataProvider.getLock() on mount.

const { data, isLoading } = useGetLock(resource, { id });
const { data, isLoading } = useGetLock(resource, { id });

Parameters description:

  • resource: the resource name (e.g. 'posts')
  • params: an object with the following properties:
    • id: the record id (e.g. 123)
    • meta: Optional. an object that will be forwarded to the dataProvider (optional)

Here is a form toolbar that displays the lock status of the current record:

const FormToolbar = () => {
    const resource = useResourceContext();
    const record = useRecordContext();
    const { isLoading: identityLoading, identity } = useGetIdentity();
    const { isLoading: lockLoading, data: lock } = useGetLock(resource, {
        id: record.id,
    });

    if (identityLoading || lockLoading) {
        return null;
    }

    const isLockedByOtherUser = lock?.identity !== identity.id;

    return (
        <div className="flex items-center gap-4">
            <button type="submit" disabled={isLockedByOtherUser}>Save</button>
            {isLockedByOtherUser && (
                <span>
                    {`This record is locked by another user: ${lock?.dentity}.`}
                </span>
            )}
        </div>
    );
};
const FormToolbar = () => {
    const resource = useResourceContext();
    const record = useRecordContext();
    const { isLoading: identityLoading, identity } = useGetIdentity();
    const { isLoading: lockLoading, data: lock } = useGetLock(resource, {
        id: record.id,
    });

    if (identityLoading || lockLoading) {
        return null;
    }

    const isLockedByOtherUser = lock?.identity !== identity.id;

    return (
        <div className="flex items-center gap-4">
            <button type="submit" disabled={isLockedByOtherUser}>
                Save
            </button>
            {isLockedByOtherUser && <span>{`This record is locked by another user: ${lock?.dentity}.`}</span>}
        </div>
    );
};

useGetLockLive

Use the useGetLockLive() hook to get the lock status in real time. This hook calls dataProvider.getLock() for the current record on mount, and subscribes to live updates on the lock/[resource]/[id] topic.

This means that if the lock is acquired or released by another user while the current user is on the page, the return value will be updated.

import { useGetLockLive } from '@react-admin/ra-core-ee';

const LockStatus = () => {
    const { data: lock } = useGetLockLive();
    const { identity } = useGetIdentity();
    if (!lock) return <span>No lock</span>;
    if (lock.identity === identity?.id) return <span>Locked by you</span>;
    return <span>Locked by {lock.identity}</span>;
};
import { useGetLockLive } from "@react-admin/ra-core-ee";

const LockStatus = () => {
    const { data: lock } = useGetLockLive();
    const { identity } = useGetIdentity();
    if (!lock) return <span>No lock</span>;
    if (lock.identity === identity?.id) return <span>Locked by you</span>;
    return <span>Locked by {lock.identity}</span>;
};

useGetLockLive reads the current resource and record id from the ResourceContext and RecordContext. You can provide them explicitly if you are not in such a context:

const { data: lock } = useGetLockLive('posts', { id: 123 });
const { data: lock } = useGetLockLive("posts", { id: 123 });

useGetLocks

Get all the locks for a given resource. Calls dataProvider.getLocks() on mount.

// simple Usage
const { data } = useGetLocks('posts');
// simple Usage
const { data } = useGetLocks("posts");

Here is how to use it in a custom list, to disable edit and delete buttons for locked records:

import { WithListContext, useRecordContext } from 'ra-core';
import { useGetLocks, type Lock } from '@react-admin/ra-core-ee';
import { DeleteButton } from '@components/ui/DeleteButton';
import { LockableEditButton } from '@components/ui/DeleteButton';

const MyPostGrid = () => {
    const resource = useResourceContext();
    const { data: locks } = useGetLocks(resource);
    return (
        <ul>
            <WithListContext
                render={({ data, isPending }) => isPending ? null : (
                    <li className="flex justify-space-between">
                        <MyPostTitle locks={locks} />
                        <MyPostActions locks={locks} />
                    </li>
                )}
            />
        </ul>
    );
};

const MyPostTitle = ({ locks }: { locks: Lock[] }) => {
    const record = useRecordContext();
    const lock = locks.find(l => l.recordId === record.id);

    return (
        <div className="flex gap-4">
            <WithRecord label="title" render={record => <span>{record.title}</span>} />} />
            {lock && (
                <span style={{ color: 'red' }}>
                    {` (Locked by ${lock.identity})`}
                </span>
            )}
        </div>
    );
};

const MyPostActions = ({ locks }: { locks: Lock[] }) => {
    const record = useRecordContext();
    const locked = locks.find(l => l.recordId === record.id);

    return (
        <div className="flex gap-4">
            <DeleteButton disabled={!!locked} />
            <LockableEditButton disabled={!!locked} />
        </div>
    );
};
import { WithListContext, useRecordContext } from "ra-core";
import { useGetLocks } from "@react-admin/ra-core-ee";
import { DeleteButton } from "@components/ui/DeleteButton";
import { LockableEditButton } from "@components/ui/DeleteButton";

const MyPostGrid = () => {
    const resource = useResourceContext();
    const { data: locks } = useGetLocks(resource);
    return (
        <ul>
            <WithListContext
                render={({ data, isPending }) =>
                    isPending ? null : (
                        <li className="flex justify-space-between">
                            <MyPostTitle locks={locks} />
                            <MyPostActions locks={locks} />
                        </li>
                    )
                }
            />
        </ul>
    );
};

const MyPostTitle = ({ locks }) => {
    const record = useRecordContext();
    const lock = locks.find((l) => l.recordId === record.id);

    return (
        <div className="flex gap-4">
            <WithRecord label="title" render={(record) => <span>{record.title}</span>} />} />
            {lock && <span style={{ color: "red" }}>{` (Locked by ${lock.identity})`}</span>}
        </div>
    );
};

const MyPostActions = ({ locks }) => {
    const record = useRecordContext();
    const locked = locks.find((l) => l.recordId === record.id);

    return (
        <div className="flex gap-4">
            <DeleteButton disabled={!!locked} />
            <LockableEditButton disabled={!!locked} />
        </div>
    );
};

useGetLocksLive

Use the useGetLocksLive hook to get the locks in real time. This hook calls dataProvider.getLocks() for the current resource on mount, and subscribes to live updates on the lock/[resource] topic.

This means that if a lock is acquired or released by another user while the current user is on the page, the return value will be updated.

import { useRecordContext } from 'ra-core';
import { useGetLocksLive } from '@react-admin/ra-core-ee';
import { Lock } from 'lucide-react';

export const LockField = ({ locks }) => {
    const record = useRecordContext();
    if (!record) return null;
    const lock = locks?.find(lock => lock.recordId === record?.id);
    if (!lock) return <span className="w-4 h-4" />;
    return <Lock className="w-4 h-4" />;
};
import { useRecordContext } from "ra-core";
import { Lock } from "lucide-react";

export const LockField = ({ locks }) => {
    const record = useRecordContext();
    if (!record) return null;
    const lock = locks?.find((lock) => lock.recordId === record?.id);
    if (!lock) return <span className="w-4 h-4" />;
    return <Lock className="w-4 h-4" />;
};

useGetLocksLive reads the current resource from the ResourceContext. You can provide it explicitly if you are not in such a context:

const { data: locks } = useGetLocksLive('posts');
const { data: locks } = useGetLocksLive("posts");

useLockCallbacks

This utility hook allows to easily get the callbacks to lock and unlock a record, as well as the current lock status.

Usage

Use this hook e.g. to build a lock button:

import { useLockCallbacks } from '@react-admin/ra-core-ee';
import { LoaderCircle, Lock } from 'lucide-react';

export const LockButton = () => {
    const {
        lock,
        isLocked,
        isLockedByCurrentUser,
        isPending,
        isLocking,
        isUnlocking,
        doLock,
        doUnlock,
    } = useLockCallbacks();

    if (isPending) {
        return null;
    }

    return isLocked ? (
        isLockedByCurrentUser ? (
            <button
                disabled={isUnlocking}
                onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
                    e.stopPropagation();
                    doUnlock();
                }}
                title="Locked by you, click to unlock"
            >
                {isUnlocking ? (
                    <LoaderCircle className="w-4 h-4 animate-spin" />
                ) : (
                    <Lock className="w-4 h-4" />
                )}
            </button>
        ) : (
            <Lock title={`Locked by another user: ${lock?.identity}`} />
        )
    ) : (
        <button
            disabled={isLocking}
            onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
                e.stopPropagation();
                doLock();
            }}
            title="Record is unlocked, click to lock"
        >
            {isLocking ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <LockOpenIcon className="w-4 h-4" />}
        </button>
    );
};
import { useLockCallbacks } from "@react-admin/ra-core-ee";
import { LoaderCircle, Lock } from "lucide-react";

export const LockButton = () => {
    const { lock, isLocked, isLockedByCurrentUser, isPending, isLocking, isUnlocking, doLock, doUnlock } =
        useLockCallbacks();

    if (isPending) {
        return null;
    }

    return isLocked ? (
        isLockedByCurrentUser ? (
            <button
                disabled={isUnlocking}
                onClick={(e) => {
                    e.stopPropagation();
                    doUnlock();
                }}
                title="Locked by you, click to unlock"
            >
                {isUnlocking ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <Lock className="w-4 h-4" />}
            </button>
        ) : (
            <Lock title={`Locked by another user: ${lock?.identity}`} />
        )
    ) : (
        <button
            disabled={isLocking}
            onClick={(e) => {
                e.stopPropagation();
                doLock();
            }}
            title="Record is unlocked, click to lock"
        >
            {isLocking ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <LockOpenIcon className="w-4 h-4" />}
        </button>
    );
};

You can also leverage this hook as a quick way to access the lock status of the current record:

import { useLockCallbacks } from '@react-admin/ra-core-ee';

export const MyToolbar = () => {
    const { isLockedByCurrentUser } = useLockCallbacks();

    return (
        <div>
            <button type="submit" disabled={!isLockedByCurrentUser}>Save</button>
        </div>
    );
};  
import { useLockCallbacks } from "@react-admin/ra-core-ee";

export const MyToolbar = () => {
    const { isLockedByCurrentUser } = useLockCallbacks();

    return (
        <div>
            <button type="submit" disabled={!isLockedByCurrentUser}>
                Save
            </button>
        </div>
    );
};
Parameters

useLockCallbacks accepts a single options parameter, with the following properties:

Name Required Type Default Value Description
identity No Identifier From AuthProvider.getIdentity() An identifier for the user who owns the lock.
resource No string From ResourceContext The resource name (e.g. 'posts').
id No Identifier From RecordContext The record id (e.g. 123).
meta No object - Additional metadata forwarded to the dataProvider lock(), unlock() and getLock() calls.
lockMutationOptions No object - react-query mutation options, used to customize the lock side-effects.
unlockMutationOptions No object - react-query mutation options, used to customize the unlock side-effects.
queryOptions No object - react-query query options, used to customize the lock query side-effects.

You can call useLockCallbacks with no parameter, and it will guess the resource and record id from the context (or the route):

const { isLocked, error, isLocking } = useLockCallbacks();
const { isLocked, error, isLocking } = useLockCallbacks();

Or you can provide them explicitly:

const { isLocked, error, isLocking } = useLockCallbacks({
    resource: 'venues',
    id: 123,
    identity: 'John Doe',
});
const { isLocked, error, isLocking } = useLockCallbacks({
    resource: "venues",
    id: 123,
    identity: "John Doe",
});
Return value

useLockCallbacks returns an object with the following properties:

Name Type Description
isLocked boolean Whether the record is currently locked (possibly by another user) or not.
isLockedByCurrentUser boolean Whether the record is locked by the current user or not.
lock object The lock data.
error object The error object if any of the mutations or the query fails.
isPending boolean Whether the lock query is in progress.
isLocking boolean Whether the lock mutation is in progress.
isUnlocking boolean Whether the unlock mutation is in progress.
doLock function A callback to manually lock the record.
doUnlock function A callback to manually unlock the record.
doLockAsync function A callback to manually lock the record asynchronously.
doUnlockAsync function A callback to manually unlock the record asynchronously.
lockQuery object The react-query query object for the lock status.
lockMutation object The react-query mutation object for the lock mutation.
unlockMutation object The react-query mutation object for the unlock mutation.

useLockOnMount

This hook locks the current record on mount.

useLockOnMount calls dataProvider.lock() on mount and dataProvider.unlock() on unmount to lock and unlock the record. It relies on authProvider.getIdentity() to get the identity of the current user. It guesses the current resource and recordId from the context (or the route) if not provided.

Usage

Use this hook e.g. in an <Edit> component to lock the record so that it only accepts updates from the current user.

import { EditBase, Form } from 'ra-core';
import { useLockOnMount } from '@react-admin/ra-core-ee';

const LockStatus = () => {
    const { isLocked, error, isLoading } = useLockOnMount();
    return (
        <div>
            {isLoading && <p>Locking post...</p>}
            {error && (
                <p>
                    <div>Failed to lock</div>
                    <div>Someone else is probably already locking it.</div>
                </p>
            )}
            {isLocked && (
                <p>
                    <div>Post locked</div>
                    <div>Only you can edit it.</div>
                </p>
            )}
        </div>
    );
};

const PostEdit = () => (
    <EditBase>
        <PostAside />
        {/* The edit form*/}
    </EditBase>
);
import { EditBase } from "ra-core";
import { useLockOnMount } from "@react-admin/ra-core-ee";

const LockStatus = () => {
    const { isLocked, error, isLoading } = useLockOnMount();
    return (
        <div>
            {isLoading && <p>Locking post...</p>}
            {error && (
                <p>
                    <div>Failed to lock</div>
                    <div>Someone else is probably already locking it.</div>
                </p>
            )}
            {isLocked && (
                <p>
                    <div>Post locked</div>
                    <div>Only you can edit it.</div>
                </p>
            )}
        </div>
    );
};

const PostEdit = () => (
    <EditBase>
        <PostAside />
        {/* The edit form*/}
    </EditBase>
);

Note: If users close their tab/browser when on a page with a locked record, useLockOnMount will block the navigation and show a notification until the record is unlocked. Hence it's a good practice to give them a way to unlock the record manually, e.g. by using the doUnlock callback returned by the hook or the <LockStatusBase> component.

Parameters

useLockOnMount accepts a single options parameter, with the following properties (all optional):

  • identity: An identifier (string or number) corresponding to the identity of the locker (e.g. 'julien'). This could be an authentication token for instance. Falls back to the identifier of the identity returned by the AuthProvider.getIdentity() function.
  • resource: The resource name (e.g. 'posts'). The hook uses the ResourceContext if not provided.
  • id: The record id (e.g. 123). The hook uses the RecordContext if not provided.
  • meta: An object that will be forwarded to the dataProvider.lock() call
  • lockMutationOptions: react-query mutation options, used to customize the lock side-effects for instance
  • unlockMutationOptions: react-query mutation options, used to customize the unlock side-effects for instance

You can call useLockOnMount with no parameter, and it will guess the resource and record id from the context (or the route):

const { isLocked, error, isLoading } = useLockOnMount();
const { isLocked, error, isLoading } = useLockOnMount();

Or you can provide them explicitly:

const { isLocked, error, isLoading } = useLockOnMount({
    resource: 'venues',
    id: 123,
    identity: 'John Doe',
});
const { isLocked, error, isLoading } = useLockOnMount({
    resource: "venues",
    id: 123,
    identity: "John Doe",
});

Tip: If the record can't be locked because another user is already locking it, you can use react-query's retry feature to try again later:

const { isLocked, error, isLoading } = useLockOnMount({
    lockMutationOptions: {
        // retry every 5 seconds, until the lock is acquired
        retry: true,
        retryDelay: 5000,
    },
});
const { isLocked, error, isLoading } = useLockOnMount({
    lockMutationOptions: {
        // retry every 5 seconds, until the lock is acquired
        retry: true,
        retryDelay: 5000,
    },
});
Return value

useLockOnMount returns an object with the following properties:

  • isLocked: Whether the record is successfully locked by this hook or not.
  • isLockedByCurrentUser: Whether the record is locked by the current user or not.
  • lock: The lock data.
  • error: The error object if the lock attempt failed.
  • isLocking: Whether the lock mutation is in progress.
  • isUnlocking: Whether the unlock mutation is in progress.
  • doLock: A callback to manually lock the record.
  • doUnlock: A callback to manually unlock the record.
  • doLockAsync: A callback to manually lock the record asynchronously.
  • doUnlockAsync: A callback to manually unlock the record asynchronously.

useLockOnCall

Get a callback to lock a record and get a mutation state.

useLockOnCall calls dataProvider.lock() when the callback is called. It relies on authProvider.getIdentity() to get the identity of the current user. It guesses the current resource and recordId from the context (or the route) if not provided. It releases the lock when the component unmounts by calling dataProvider.unlock().

Usage

Use this hook in a toolbar, to let the user lock the record manually.

import { EditBase } from 'ra-core';
import { useLockOnMount } from '@react-admin/ra-core-ee';
import { Alert, AlertTitle, Box, Button } from '@material-ui/core';

const LockStatus = () => {
    const [doLock, { data, error, isLoading }] = useLockOnCall();
    return (
        <div>
            {isLoading ? (
                <div>Locking post...</div>
            ) : error ? (
                <div>
                    <div>Failed to lock</div>
                    <div>Someone else is probably already locking it.</div>
                </div>
            ) : data ? (
                <div>
                    <div>Post locked</div> 
                    <div>Only you can edit it.</div>
                </div>
            ) : (
                <button onClick={() => { doLock(); }}>
                    Lock post
                </button>
            )}
        </div>
    );
};

const PostEdit = () => (
    <EditBase>
        <PostAside />
        {/* The edit form*/}
    </EditBase>
);
import { EditBase } from "ra-core";

const LockStatus = () => {
    const [doLock, { data, error, isLoading }] = useLockOnCall();
    return (
        <div>
            {isLoading ? (
                <div>Locking post...</div>
            ) : error ? (
                <div>
                    <div>Failed to lock</div>
                    <div>Someone else is probably already locking it.</div>
                </div>
            ) : data ? (
                <div>
                    <div>Post locked</div>
                    <div>Only you can edit it.</div>
                </div>
            ) : (
                <button
                    onClick={() => {
                        doLock();
                    }}
                >
                    Lock post
                </button>
            )}
        </div>
    );
};

const PostEdit = () => (
    <EditBase>
        <PostAside />
        {/* The edit form*/}
    </EditBase>
);

Note: If users close their tab/browser when on a page with a locked record, useLockOnCall will block the navigation and show a notification until the record is unlocked. Hence it's a good practice to give them a way to unlock the record manually, e.g. by using the doUnlock callback returned by the useLockCallbacks hook or the <LockStatusBase> component.

Parameters

useLockOnCall accepts a single options parameter, with the following properties (all optional):

  • identity: An identifier (string or number) corresponding to the identity of the locker (e.g. 'julien'). This could be an authentication token for instance. Falls back to the identifier of the identity returned by the AuthProvider.getIdentity() function.
  • resource: The resource name (e.g. 'posts'). The hook uses the ResourceContext if not provided.
  • id: The record id (e.g. 123). The hook uses the RecordContext if not provided.
  • meta: An object that will be forwarded to the dataProvider.lock() call
  • lockMutationOptions: react-query mutation options, used to customize the lock side-effects for instance
  • unlockMutationOptions: react-query mutation options, used to customize the unlock side-effects for instance
const LockButton = ({ resource, id, identity }) => {
    const [doLock, lockMutation] = useLockOnCall({ resource, id, identity });
    return (
        <button onClick={() => {doLock();}} disabled={lockMutation.isLoading}>
            Lock
        </button>
    );
};
const LockButton = ({ resource, id, identity }) => {
    const [doLock, lockMutation] = useLockOnCall({ resource, id, identity });
    return (
        <button
            onClick={() => {
                doLock();
            }}
            disabled={lockMutation.isLoading}
        >
            Lock
        </button>
    );
};

useGetListLive

Alternative to useGetList that subscribes to live updates on the record list.

import { useGetListLive } from '@react-admin/ra-core-ee';

const LatestNews = () => {
    const { data, total, isLoading, error } = useGetListLive('posts', {
        pagination: { page: 1, perPage: 10 },
        sort: { field: 'published_at', order: 'DESC' },
    });
    if (isLoading) {
        return <div>Loading...</div>;
    }
    if (error) {
        return <p>ERROR</p>;
    }

    return (
        <ul>
            {data.map(item => (
                <li key={item.id}>{item.title}</li>
            ))}
        </ul>
    );
};
import { useGetListLive } from "@react-admin/ra-core-ee";

const LatestNews = () => {
    const { data, total, isLoading, error } = useGetListLive("posts", {
        pagination: { page: 1, perPage: 10 },
        sort: { field: "published_at", order: "DESC" },
    });
    if (isLoading) {
        return <div>Loading...</div>;
    }
    if (error) {
        return <p>ERROR</p>;
    }

    return (
        <ul>
            {data.map((item) => (
                <li key={item.id}>{item.title}</li>
            ))}
        </ul>
    );
};

The hook will subscribe to live updates on the list of records (topic: resource/[resource]) and will refetch the list when a new record is created, or an existing record is updated or deleted.

See the useGetList documentation for the full list of parameters and return type.

useGetOneLive

Alternative to useGetOne() that subscribes to live updates on the record

import { useRecordContext } from 'ra-core';
import { useGetOneLive } from '@react-admin/ra-core-ee';

const UserProfile = () => {
    const record = useRecordContext();
    const { data, isLoading, error } = useGetOneLive('users', {
        id: record.id,
    });
    if (isLoading) {
        return <div>Loading...</div>;
    }
    if (error) {
        return <p>ERROR</p>;
    }
    return <div>User {data.username}</div>;
};
import { useRecordContext } from "ra-core";
import { useGetOneLive } from "@react-admin/ra-core-ee";

const UserProfile = () => {
    const record = useRecordContext();
    const { data, isLoading, error } = useGetOneLive("users", {
        id: record.id,
    });
    if (isLoading) {
        return <div>Loading...</div>;
    }
    if (error) {
        return <p>ERROR</p>;
    }
    return <div>User {data.username}</div>;
};

The hook will subscribe to live updates on the record (topic: resource/[resource]/[id]) and will refetch the record when it is updated or deleted.

See the useGetOne documentation for the full list of parameters and return type.

Components

<LockStatusBase>

Use the <LockStatusBase> component to display the lock status of the record in the nearest RecordContext:

import React from 'react';
import { Lock, LockOpen, LoaderCircle } from 'lucide-react';
import { LockStatusBase } from '@react-admin/ra-core-ee';

export const LockStatus = () => {
    return (
        <LockStatusBase
            {...props}
            render={({
                doLock,
                doUnlock,
                isLocking,
                isPending,
                isUnlocking,
                lockStatus,
                message,
            }) => {
                if (isPending) {
                    return null;
                }

                if (lockStatus === 'lockedByUser') {
                    return (
                        <button
                            title={message}
                            disabled={isUnlocking}
                            onClick={(
                                e: React.MouseEvent<HTMLButtonElement>
                            ) => {
                                e.stopPropagation();
                                doUnlock();
                            }}
                        >
                            {isUnlocking ? (
                                <LoaderCircle className="h-4 w-4 animate-spin" />
                            ) : (
                                <Lock className="h-4 w-4" />
                            )}
                        </button>
                    );
                }
                if (lockStatus === 'lockedByAnotherUser') {
                    return (
                        <Lock className="h-4 w-4 text-error" />
                    );
                }
                if (lockStatus === 'unlocked') {
                    return (
                        <button
                            title={message}
                            disabled={isLocking}
                            onClick={(
                                e: React.MouseEvent<HTMLButtonElement>
                            ) => {
                                e.stopPropagation();
                                doLock();
                            }}
                            color="warning"
                        >
                            {isLocking ? (
                                <LoaderCircle className="h-4 w-4 animate-spin" />
                            ) : (
                                <LockOpen className="h-4 w-4" />
                            )}
                        </button>
                    );
                }
                return null;
            }}
        />
    );
};
import React from "react";
import { Lock, LockOpen, LoaderCircle } from "lucide-react";
import { LockStatusBase } from "@react-admin/ra-core-ee";

export const LockStatus = () => {
    return (
        <LockStatusBase
            {...props}
            render={({ doLock, doUnlock, isLocking, isPending, isUnlocking, lockStatus, message }) => {
                if (isPending) {
                    return null;
                }

                if (lockStatus === "lockedByUser") {
                    return (
                        <button
                            title={message}
                            disabled={isUnlocking}
                            onClick={(e) => {
                                e.stopPropagation();
                                doUnlock();
                            }}
                        >
                            {isUnlocking ? (
                                <LoaderCircle className="h-4 w-4 animate-spin" />
                            ) : (
                                <Lock className="h-4 w-4" />
                            )}
                        </button>
                    );
                }
                if (lockStatus === "lockedByAnotherUser") {
                    return <Lock className="h-4 w-4 text-error" />;
                }
                if (lockStatus === "unlocked") {
                    return (
                        <button
                            title={message}
                            disabled={isLocking}
                            onClick={(e) => {
                                e.stopPropagation();
                                doLock();
                            }}
                            color="warning"
                        >
                            {isLocking ? (
                                <LoaderCircle className="h-4 w-4 animate-spin" />
                            ) : (
                                <LockOpen className="h-4 w-4" />
                            )}
                        </button>
                    );
                }
                return null;
            }}
        />
    );
};

In addition to the useLockCallbacks parameters, <LockStatusBase> accepts a render prop. The function passed to the render prop will be called with the result of the useLockCallbacks hook.

I18N

This module uses specific translations for displaying notifications. As for all translations in react-admin, it's possible to customize the messages.

To create your own translations, you can use the TypeScript types to see the structure and see which keys are overridable.

Here is an example of how to customize translations in your app:

import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import {
    raRealTimeLanguageEnglish,
    raRealTimeLanguageFrench,
    type RaRealTimeTranslationMessages,
} from '@react-admin/ra-core-ee';
import { CoreAdmin, mergeTranslations, type TranslationMessages as BaseTranslationMessages } from 'ra-core';

/* TranslationMessages extends the default translation
 * Type from ra-core (BaseTranslationMessages)
 * and the ra-realtime translation Type (RaRealTimeTranslationMessages)
 */
interface TranslationMessages
    extends RaRealTimeTranslationMessages,
        BaseTranslationMessages {}

const customEnglishMessages: TranslationMessages = mergeTranslations(
    englishMessages,
    raRealTimeLanguageEnglish,
    {
        'ra-realtime': {
            notification: {
                record: {
                    updated: 'Wow, this entry has been modified by a ghost',
                    deleted: 'Hey, a ghost has stolen this entry',
                },
            },
        },
    }
);

const i18nCustomProvider = polyglotI18nProvider(locale => {
    if (locale === 'fr') {
        return mergeTranslations(frenchMessages, raRealTimeLanguageFrench);
    }
    return customEnglishMessages;
}, 'en');

export const MyApp = () => (
    <CoreAdmin i18nProvider={i18nCustomProvider}>
        ...
    </CoreAdmin>
);
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";
import { raRealTimeLanguageEnglish, raRealTimeLanguageFrench } from "@react-admin/ra-core-ee";
import { CoreAdmin, mergeTranslations } from "ra-core";

const customEnglishMessages = mergeTranslations(englishMessages, raRealTimeLanguageEnglish, {
    "ra-realtime": {
        notification: {
            record: {
                updated: "Wow, this entry has been modified by a ghost",
                deleted: "Hey, a ghost has stolen this entry",
            },
        },
    },
});

const i18nCustomProvider = polyglotI18nProvider((locale) => {
    if (locale === "fr") {
        return mergeTranslations(frenchMessages, raRealTimeLanguageFrench);
    }
    return customEnglishMessages;
}, "en");

export const MyApp = () => <CoreAdmin i18nProvider={i18nCustomProvider}>...</CoreAdmin>;

Soft Delete

Data Provider

ra-core-ee relies on the dataProvider to soft-delete, restore or view deleted records. In order to use the soft delete feature from ra-core-ee, you must add a few new methods to your data provider:

  • softDelete performs the soft deletion of the provided record.
  • softDeleteMany performs the soft deletion of the provided records.
  • getOneDeleted gets one deleted record by its ID.
  • getListDeleted gets a list of deleted records with filters and sort.
  • restoreOne restores a deleted record.
  • restoreMany restores deleted records.
  • hardDelete permanently deletes a record.
  • hardDeleteMany permanently deletes many records.
  • (OPTIONAL) createMany creates multiple records at once. This method is used internally by some data provider implementations to delete or restore multiple records at once. As it is optional, a default implementation is provided that simply calls create multiple times.
const dataProviderWithSoftDelete: SoftDeleteDataProvider = {
    ...dataProvider,

    softDelete: (resource, params: SoftDeleteParams): SoftDeleteResult => {
        const { id, authorId } = params;
        // ...
        return { data: deletedRecord };
    },
    softDeleteMany: (resource, params: SoftDeleteManyParams): SoftDeleteManyResult => {
        const { ids, authorId } = params;
        // ...
        return { data: deletedRecords };
    },

    getOneDeleted: (params: GetOneDeletedParams): GetOneDeletedResult => {
        const { id } = params;
        // ...
        return { data: deletedRecord };
    },
    getListDeleted: (params: GetListDeletedParams): GetListDeletedResult => {
        const { filter, sort, pagination } = params;
        // ...
        return { data: deletedRecords, total: deletedRecords.length };
    },

    restoreOne: (params: RestoreOneParams): RestoreOneResult => {
        const { id } = params;
        // ...
        return { data: deletedRecord };
    },
    restoreMany: (params: RestoreManyParams): RestoreManyResult => {
        const { ids } = params;
        // ...
        return { data: deletedRecords };
    },

    hardDelete: (params: HardDeleteParams): HardDeleteResult => {
        const { id } = params;
        // ...
        return { data: deletedRecordId };
    },
    hardDeleteMany: (params: HardDeleteManyParams): HardDeleteManyResult => {
        const { ids } = params;
        // ...
        return { data: deletedRecordsIds };
    },
};
const dataProviderWithSoftDelete = {
    ...dataProvider,

    softDelete: (resource, params) => {
        const { id, authorId } = params;
        // ...
        return { data: deletedRecord };
    },
    softDeleteMany: (resource, params) => {
        const { ids, authorId } = params;
        // ...
        return { data: deletedRecords };
    },

    getOneDeleted: (params) => {
        const { id } = params;
        // ...
        return { data: deletedRecord };
    },
    getListDeleted: (params) => {
        const { filter, sort, pagination } = params;
        // ...
        return { data: deletedRecords, total: deletedRecords.length };
    },

    restoreOne: (params) => {
        const { id } = params;
        // ...
        return { data: deletedRecord };
    },
    restoreMany: (params) => {
        const { ids } = params;
        // ...
        return { data: deletedRecords };
    },

    hardDelete: (params) => {
        const { id } = params;
        // ...
        return { data: deletedRecordId };
    },
    hardDeleteMany: (params) => {
        const { ids } = params;
        // ...
        return { data: deletedRecordsIds };
    },
};

Tip: ra-core-ee will automatically populate the authorId using your authProvider's getIdentity method if there is one. It will use the id field of the returned identity object. Otherwise this field will be left blank.

Tip: Deleted records are immutable, so you don't need to implement an updateDeleted method.

A deleted record is an object with the following properties:

{
    id: 123,
    resource: "products",
    deleted_at: "2025-06-06T15:32:22Z",
    deleted_by: "johndoe",
    data: {
        id: 456,
        title: "Lorem ipsum",
        teaser: "Lorem ipsum dolor sit amet",
        body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
    },
}

ra-core-ee soft delete comes with two built-in implementations that will add soft delete capabilities to your data provider:

  • addSoftDeleteBasedOnResource stores the deleted records for all resources in a single deleted_records (configurable) resource.
  • addSoftDeleteInPlace keeps the deleted records in the same resource, but fills deleted_at (configurable) and deleted_by (configurable) fields.

You can also write your own implementation. Feel free to look at these builders source code for inspiration. You can find it under your node_modules folder, e.g. at node_modules/@react-admin/ra-core-ee/src/dataProvider/addSoftDeleteBasedOnResource.ts.

Each data provider verb has its own hook so you can use them in custom components:

addSoftDeleteBasedOnResource

Use addSoftDeleteBasedOnResource to add the soft delete capabilities to your data provider, storing all deleted records in a single deleted_records (configurable) resource.

// in src/dataProvider.ts
import { addSoftDeleteBasedOnResource } from '@react-admin/ra-core-ee';
import baseDataProvider from './baseDataProvider';

export const dataProvider = addSoftDeleteBasedOnResource(
    baseDataProvider,
    { deletedRecordsResourceName: 'deleted_records' }
);
// in src/dataProvider.ts
import { addSoftDeleteBasedOnResource } from "@react-admin/ra-core-ee";
import baseDataProvider from "./baseDataProvider";

export const dataProvider = addSoftDeleteBasedOnResource(baseDataProvider, {
    deletedRecordsResourceName: "deleted_records",
});

addSoftDeleteInPlace

Use addSoftDeleteInPlace to add the soft delete capabilities to your data provider, keeping the deleted records in the same resource. This implementation will simply fill the deleted_at (configurable) and deleted_by (configurable) fields.

You'll need to pass an object with all your resources as key so that getListDeleted knows where to look for deleted records.

Note on performances: Avoid calling getListDeleted without a resource filter, as it uses a naive implementation combining multiple getList calls, which can lead to bad performances. It is recommended to use one list per resource in this case (see resource property).

// in src/dataProvider.ts
import { addSoftDeleteInPlace } from '@react-admin/ra-core-ee';
import baseDataProvider from './baseDataProvider';

export const dataProvider = addSoftDeleteInPlace(
    baseDataProvider,
    {
        posts: {},
        comments: {
            deletedAtFieldName: 'deletion_date',
        },
        accounts: {
            deletedAtFieldName: 'disabled_at',
            deletedByFieldName: 'disabled_by',
        }
    }
);
// in src/dataProvider.ts
import { addSoftDeleteInPlace } from "@react-admin/ra-core-ee";
import baseDataProvider from "./baseDataProvider";

export const dataProvider = addSoftDeleteInPlace(baseDataProvider, {
    posts: {},
    comments: {
        deletedAtFieldName: "deletion_date",
    },
    accounts: {
        deletedAtFieldName: "disabled_at",
        deletedByFieldName: "disabled_by",
    },
});

createMany

ra-core-ee provides a default implementation of the createMany method that simply calls create multiple times. However, some data providers may be able to create multiple records at once, which can greatly improve performances.

const dataProviderWithCreateMany = {
    ...dataProvider,
    createMany: (resource, params: CreateManyParams): CreateManyResult => {
        const {data} = params; // data is an array of records.
        // ...
        return {data: createdRecords};
    },
};
const dataProviderWithCreateMany = {
    ...dataProvider,
    createMany: (resource, params) => {
        const { data } = params; // data is an array of records.
        // ...
        return { data: createdRecords };
    },
};

I18N

This module uses specific translations for new buttons, notifications and confirm dialogs. Here is how to set up the i18n provider to use the default translations.

import { CoreAdmin, mergeTranslations } from 'ra-core';
import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import {
    raSoftDeleteLanguageEnglish,
    raSoftDeleteLanguageFrench,
} from '@react-admin/ra-core-ee';

const i18nProvider = polyglotI18nProvider(
    locale =>
        locale === 'fr'
            ? mergeTranslations(frenchMessages, raSoftDeleteLanguageFrench)
            : mergeTranslations(englishMessages, raSoftDeleteLanguageEnglish),
    'en',
    [
        { locale: 'en', name: 'English' },
        { locale: 'fr', name: 'Français' },
    ]
);

export const MyApp = () => (
    <CoreAdmin i18nProvider={i18nProvider}>
        ...
    </CoreAdmin>
);
import { CoreAdmin, mergeTranslations } from "ra-core";
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";
import { raSoftDeleteLanguageEnglish, raSoftDeleteLanguageFrench } from "@react-admin/ra-core-ee";

const i18nProvider = polyglotI18nProvider(
    (locale) =>
        locale === "fr"
            ? mergeTranslations(frenchMessages, raSoftDeleteLanguageFrench)
            : mergeTranslations(englishMessages, raSoftDeleteLanguageEnglish),
    "en",
    [
        { locale: "en", name: "English" },
        { locale: "fr", name: "Français" },
    ]
);

export const MyApp = () => <CoreAdmin i18nProvider={i18nProvider}>...</CoreAdmin>;

As for all translations in ra-core, it's possible to customize the messages. To create your own translations, you can use the TypeScript types to see the structure and see which keys are overridable. Here is an example of how to customize translations in your app:

import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import {
    raSoftDeleteLanguageEnglish,
    raSoftDeleteLanguageFrench,
    type RaSoftDeleteTranslationMessages,
} from '@react-admin/ra-core-ee';
import type { TranslationMessages as BaseTranslationMessages } from 'ra-core';

/* TranslationMessages extends the defaut translation
 * Type from ra-core (BaseTranslationMessages)
 * and the ra-core-ee soft delete translation Type (RaSoftDeleteTranslationMessages)
 */
interface TranslationMessages
    extends RaSoftDeleteTranslationMessages,
        BaseTranslationMessages {}

const customEnglishMessages: TranslationMessages = mergeTranslations(
    englishMessages,
    raSoftDeleteLanguageEnglish,
    {
        'ra-soft-delete': {
            action: {
                soft_delete: 'Archive element',
            },
            notification: {
                soft_deleted: 'Element archived |||| %{smart_count} elements archived',
                deleted_permanently: 'Element deleted permanently |||| %{smart_count} elements deleted permanently',
            },
        },
    }
);

const i18nCustomProvider = polyglotI18nProvider(locale => {
    if (locale === 'fr') {
        return mergeTranslations(frenchMessages, raSoftDeleteLanguageFrench);
    }
    return customEnglishMessages;
}, 'en');

export const MyApp = () => (
    <CoreAdmin dataProvider={myDataprovider} i18nProvider={i18nCustomProvider}>
        ...
    </CoreAdmin>
);
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";
import { raSoftDeleteLanguageEnglish, raSoftDeleteLanguageFrench } from "@react-admin/ra-core-ee";

const customEnglishMessages = mergeTranslations(englishMessages, raSoftDeleteLanguageEnglish, {
    "ra-soft-delete": {
        action: {
            soft_delete: "Archive element",
        },
        notification: {
            soft_deleted: "Element archived |||| %{smart_count} elements archived",
            deleted_permanently: "Element deleted permanently |||| %{smart_count} elements deleted permanently",
        },
    },
});

const i18nCustomProvider = polyglotI18nProvider((locale) => {
    if (locale === "fr") {
        return mergeTranslations(frenchMessages, raSoftDeleteLanguageFrench);
    }
    return customEnglishMessages;
}, "en");

export const MyApp = () => (
    <CoreAdmin dataProvider={myDataprovider} i18nProvider={i18nCustomProvider}>
        ...
    </CoreAdmin>
);

Hooks

useSoftDelete

This hook allows calling dataProvider.softDelete() when the callback is executed and deleting a single record based on its id.

const [softDeleteOne, { data, isPending, error }] = useSoftDelete(
    resource,
    { id, authorId, previousData, meta },
    options,
);
const [softDeleteOne, { data, isPending, error }] = useSoftDelete(
    resource,
    { id, authorId, previousData, meta },
    options
);

The softDeleteOne() method can be called with the same parameters as the hook:

const [softDeleteOne, { data, isPending, error }] = useSoftDelete();

// ...

softDeleteOne(
    resource,
    { id, authorId, previousData, meta },
    options,
);
const [softDeleteOne, { data, isPending, error }] = useSoftDelete();

// ...

softDeleteOne(resource, { id, authorId, previousData, meta }, options);

So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the softDeleteOne callback (second example).

Tip: If it's not provided, useSoftDelete will automatically populate the authorId using your authProvider's getIdentity method if there is one. It will use the id field of the returned identity object. Otherwise this field will be left blank.

Usage
// set params when calling the hook
import { useRecordContext } from 'ra-core';
import { useSoftDelete } from '@react-admin/ra-core-ee';

const SoftDeleteButton = () => {
    const record = useRecordContext();
    const [softDeleteOne, { isPending, error }] = useSoftDelete(
        'likes',
        { id: record.id, previousData: record }
    );
    const handleClick = () => {
        softDeleteOne();
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete</button>;
};

// set params when calling the softDeleteOne callback
import { useRecordContext } from 'ra-core';
import { useSoftDelete } from '@react-admin/ra-core-ee';

const SoftDeleteButton = () => {
    const record = useRecordContext();
    const [softDeleteOne, { isPending, error }] = useSoftDelete();
    const handleClick = () => {
        softDeleteOne(
            'likes',
            { id: record.id, previousData: record }
        );
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete</button>;
};
// set params when calling the hook
import { useRecordContext } from "ra-core";
import { useSoftDelete } from "@react-admin/ra-core-ee";

const SoftDeleteButton = () => {
    const record = useRecordContext();
    const [softDeleteOne, { isPending, error }] = useSoftDelete("likes", { id: record.id, previousData: record });
    const handleClick = () => {
        softDeleteOne();
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete
        </button>
    );
};

const SoftDeleteButton = () => {
    const record = useRecordContext();
    const [softDeleteOne, { isPending, error }] = useSoftDelete();
    const handleClick = () => {
        softDeleteOne("likes", { id: record.id, previousData: record });
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete
        </button>
    );
};
TypeScript

The useSoftDelete hook accepts a generic parameter for the record type and another for the error type:

useSoftDelete<Product, Error>(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type Product
        // TypeScript knows that error is of type Error
    },
});
useSoftDelete(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type Product
        // TypeScript knows that error is of type Error
    },
});

useSoftDeleteMany

This hook allows calling dataProvider.softDeleteMany() when the callback is executed and deleting an array of records based on their ids.

const [softDeleteMany, { data, isPending, error }] = useSoftDeleteMany(
    resource,
    { ids, authorId, meta },
    options,
);
const [softDeleteMany, { data, isPending, error }] = useSoftDeleteMany(resource, { ids, authorId, meta }, options);

The softDeleteMany() method can be called with the same parameters as the hook:

const [softDeleteMany, { data, isPending, error }] = useSoftDeleteMany();

// ...

softDeleteMany(
    resource,
    { ids, authorId, meta },
    options,
);
const [softDeleteMany, { data, isPending, error }] = useSoftDeleteMany();

// ...

softDeleteMany(resource, { ids, authorId, meta }, options);

So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the softDeleteMany callback (second example).

Tip: If it's not provided, useSoftDeleteMany will automatically populate the authorId using your authProvider's getIdentity method if there is one. It will use the id field of the returned identity object. Otherwise this field will be left blank.

Usage
// set params when calling the hook
import { useListContext } from 'ra-core';
import { useSoftDeleteMany } from '@react-admin/ra-core-ee';

const BulkSoftDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [softDeleteMany, { isPending, error }] = useSoftDeleteMany(
        'posts',
        { ids: selectedIds }
    );
    const handleClick = () => {
        softDeleteMany();
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete selected posts</button>;
};

// set params when calling the softDeleteMany callback
import { useListContext } from 'ra-core';
import { useSoftDeleteMany } from '@react-admin/ra-core-ee';

const BulkSoftDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [softDeleteMany, { isPending, error }] = useSoftDeleteMany();
    const handleClick = () => {
        softDeleteMany(
            'posts',
            { ids: seletedIds }
        );
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete selected posts</button>;
};
// set params when calling the hook
import { useListContext } from "ra-core";
import { useSoftDeleteMany } from "@react-admin/ra-core-ee";

const BulkSoftDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [softDeleteMany, { isPending, error }] = useSoftDeleteMany("posts", { ids: selectedIds });
    const handleClick = () => {
        softDeleteMany();
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete selected posts
        </button>
    );
};

const BulkSoftDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [softDeleteMany, { isPending, error }] = useSoftDeleteMany();
    const handleClick = () => {
        softDeleteMany("posts", { ids: seletedIds });
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete selected posts
        </button>
    );
};
TypeScript

The useSoftDeleteMany hook accepts a generic parameter for the record type and another for the error type:

useSoftDeleteMany<Product, Error>(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type Product[]
        // TypeScript knows that error is of type Error
    },
});
useSoftDeleteMany(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type Product[]
        // TypeScript knows that error is of type Error
    },
});

useGetListDeleted

This hook calls dataProvider.getListDeleted() when the component mounts. It's ideal for getting a list of deleted records. It supports filtering, sorting and pagination.

const { data, total, isPending, error, refetch, meta } = useGetListDeleted(
    {
        pagination: { page, perPage },
        sort: { field, order },
        filter,
        meta
    },
    options
);
const { data, total, isPending, error, refetch, meta } = useGetListDeleted(
    {
        pagination: { page, perPage },
        sort: { field, order },
        filter,
        meta,
    },
    options
);

The meta argument is optional. It can be anything you want to pass to the data provider, e.g. a list of fields to show in the result. It is distinct from the meta property of the response, which may contain additional metadata returned by the data provider.

The options parameter is optional, and is passed to react-query's useQuery hook. Check react-query's useQuery hook documentation for details on all available option.

The react-query query key for this hook is ['getListDeleted', { pagination, sort, filter, meta }].

Usage

Call the useGetListDeleted hook when you need to fetch a list of deleted records from the data provider.

import { useGetListDeleted } from '@react-admin/ra-core-ee';

const LatestDeletedPosts = () => {
    const { data, total, isPending, error } = useGetListDeleted(
        { 
            filter: { resource: "posts" },
            pagination: { page: 1, perPage: 10 },
            sort: { field: 'deleted_at', order: 'DESC' }
        }
    );
    if (isPending) { return <Loading />; }
    if (error) { return <p>ERROR</p>; }
    return (
        <>
            <h1>Latest deleted posts</h1>
            <ul>
                {data.map(deletedRecord =>
                    <li key={deletedRecord.id}>{deletedRecord.data.title}</li>
                )}
            </ul>
            <p>{data.length} / {total} deleted posts</p>
        </>
    );
};
import { useGetListDeleted } from "@react-admin/ra-core-ee";

const LatestDeletedPosts = () => {
    const { data, total, isPending, error } = useGetListDeleted({
        filter: { resource: "posts" },
        pagination: { page: 1, perPage: 10 },
        sort: { field: "deleted_at", order: "DESC" },
    });
    if (isPending) {
        return <Loading />;
    }
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <>
            <h1>Latest deleted posts</h1>
            <ul>
                {data.map((deletedRecord) => (
                    <li key={deletedRecord.id}>{deletedRecord.data.title}</li>
                ))}
            </ul>
            <p>
                {data.length} / {total} deleted posts
            </p>
        </>
    );
};

If you need to learn more about pagination, sort or filter, please refer to useGetList documentation, as useGetListDeleted implements these parameters the same way.

TypeScript

The useGetListDeleted hook accepts a generic parameter for the record type:

import { useGetListDeleted } from '@react-admin/ra-core-ee';

const LatestDeletedPosts = () => {
    const { data, total, isPending, error } = useGetListDeleted<Post>(
        { 
            filter: { resource: "posts" },
            pagination: { page: 1, perPage: 10 },
            sort: { field: 'deleted_at', order: 'DESC' }
        }
    );
    if (isPending) { return <Loading />; }
    if (error) { return <p>ERROR</p>; }
    return (
        <>
            <h1>Latest deleted posts</h1>
            <ul>
                {/* TypeScript knows that data is of type DeletedRecordType<Post>[] */}
                {data.map(deletedRecord =>
                    <li key={deletedRecord.id}>{deletedRecord.data.title}</li>
                )}
            </ul>
            <p>{data.length} / {total} deleted posts</p>
        </>
    );
};
import { useGetListDeleted } from "@react-admin/ra-core-ee";

const LatestDeletedPosts = () => {
    const { data, total, isPending, error } = useGetListDeleted({
        filter: { resource: "posts" },
        pagination: { page: 1, perPage: 10 },
        sort: { field: "deleted_at", order: "DESC" },
    });
    if (isPending) {
        return <Loading />;
    }
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <>
            <h1>Latest deleted posts</h1>
            <ul>
                {/* TypeScript knows that data is of type DeletedRecordType<Post>[] */}
                {data.map((deletedRecord) => (
                    <li key={deletedRecord.id}>{deletedRecord.data.title}</li>
                ))}
            </ul>
            <p>
                {data.length} / {total} deleted posts
            </p>
        </>
    );
};

useGetOneDeleted

This hook calls dataProvider.getOneDeleted() when the component mounts. It queries the data provider for a single deleted record, based on its id.

const { data, isPending, error, refetch } = useGetOne(
    { id, meta },
    options
);
const { data, isPending, error, refetch } = useGetOne({ id, meta }, options);

The meta argument is optional. It can be anything you want to pass to the data provider, e.g. a list of fields to show in the result.

The options parameter is optional, and is passed to react-query's useQuery hook. Check react-query's useQuery hook documentation for details on all available option.

The react-query query key for this hook is ['getOneDeleted', { id: String(id), meta }].

Usage

Call useGetOneDeleted in a component to query the data provider for a single deleted record, based on its id.

import { useGetOneDeleted } from '@react-admin/ra-core-ee';

const DeletedUser = ({ deletedUserId }) => {
    const { data: deletedUser, isPending, error } = useGetOneDeleted({ id: deletedUserId });
    if (isPending) { return <Loading />; }
    if (error) { return <p>ERROR</p>; }
    return <div>User {deletedUser.data.username} (deleted by {deletedUser.deleted_by})</div>;
};
import { useGetOneDeleted } from "@react-admin/ra-core-ee";

const DeletedUser = ({ deletedUserId }) => {
    const { data: deletedUser, isPending, error } = useGetOneDeleted({ id: deletedUserId });
    if (isPending) {
        return <Loading />;
    }
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <div>
            User {deletedUser.data.username} (deleted by {deletedUser.deleted_by})
        </div>
    );
};
TypeScript

The useGetOneDeleted hook accepts a generic parameter for the record type:

import { useGetOneDeleted } from '@react-admin/ra-core-ee';

const DeletedUser = ({ deletedUserId }) => {
    const { data: deletedUser, isPending, error } = useGetOneDeleted<User>({ id: deletedUserId });
    if (isPending) { return <Loading />; }
    if (error) { return <p>ERROR</p>; }
    // TypeScript knows that deletedUser.data is of type User
    return <div>User {deletedUser.data.username} (deleted by {deletedUser.deleted_by})</div>;
};
import { useGetOneDeleted } from "@react-admin/ra-core-ee";

const DeletedUser = ({ deletedUserId }) => {
    const { data: deletedUser, isPending, error } = useGetOneDeleted({ id: deletedUserId });
    if (isPending) {
        return <Loading />;
    }
    if (error) {
        return <p>ERROR</p>;
    }
    // TypeScript knows that deletedUser.data is of type User
    return (
        <div>
            User {deletedUser.data.username} (deleted by {deletedUser.deleted_by})
        </div>
    );
};

useRestoreOne

This hook allows calling dataProvider.restoreOne() when the callback is executed and restoring a single deleted record based on its id.

Warning: The id here is the ID of the deleted record, and not the ID of the actual record that has been deleted.

const [restoreOne, { data, isPending, error }] = useRestoreOne(
    { id, meta },
    options,
);
const [restoreOne, { data, isPending, error }] = useRestoreOne({ id, meta }, options);

The restoreOne() method can be called with the same parameters as the hook:

const [restoreOne, { data, isPending, error }] = useRestoreOne();

// ...

restoreOne(
    { id, meta },
    options,
);
const [restoreOne, { data, isPending, error }] = useRestoreOne();

// ...

restoreOne({ id, meta }, options);

So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the restoreOne callback (second example).

Usage
// set params when calling the hook
import { useRecordContext } from 'ra-core';
import { useRestoreOne } from '@react-admin/ra-core-ee';

const RestoreButton = () => {
    const deletedRecord = useRecordContext();
    const [restoreOne, { isPending, error }] = useRestoreOne(
        { id: deletedRecord.id }
    );
    const handleClick = () => {
        restoreOne();
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Restore</button>;
};

// set params when calling the restoreOne callback
import { useRecordContext } from 'ra-core';
import { useRestoreOne } from '@react-admin/ra-core-ee';

const HardDeleteButton = () => {
    const deletedRecord = useRecordContext();
    const [restoreOne, { isPending, error }] = useRestoreOne();
    const handleClick = () => {
        restoreOne(
            { id: deletedRecord.id }
        );
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Restore</button>;
};
// set params when calling the hook
import { useRecordContext } from "ra-core";
import { useRestoreOne } from "@react-admin/ra-core-ee";

const RestoreButton = () => {
    const deletedRecord = useRecordContext();
    const [restoreOne, { isPending, error }] = useRestoreOne({ id: deletedRecord.id });
    const handleClick = () => {
        restoreOne();
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Restore
        </button>
    );
};

const HardDeleteButton = () => {
    const deletedRecord = useRecordContext();
    const [restoreOne, { isPending, error }] = useRestoreOne();
    const handleClick = () => {
        restoreOne({ id: deletedRecord.id });
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Restore
        </button>
    );
};
TypeScript

The useRestoreOne hook accepts a generic parameter for the record type and another for the error type:

useRestoreOne<Product, Error>(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type DeletedRecordType<Product>
        // TypeScript knows that error is of type Error
    },
});
useRestoreOne(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type DeletedRecordType<Product>
        // TypeScript knows that error is of type Error
    },
});

useRestoreMany

This hook allows calling dataProvider.restoreMany() when the callback is executed and restoring an array of deleted records based on their ids.

Warning: The ids here are the IDs of the deleted records, and not the IDs of the actual records that have been deleted.

const [restoreMany, { data, isPending, error }] = useRestoreMany(
    { ids, meta },
    options,
);
const [restoreMany, { data, isPending, error }] = useRestoreMany({ ids, meta }, options);

The restoreMany() method can be called with the same parameters as the hook:

const [restoreMany, { data, isPending, error }] = useRestoreMany();

// ...

restoreMany(
    { ids, meta },
    options,
);
const [restoreMany, { data, isPending, error }] = useRestoreMany();

// ...

restoreMany({ ids, meta }, options);

So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the restoreMany callback (second example).

Usage
// set params when calling the hook
import { useListContext } from 'ra-core';
import { useRestoreMany } from '@react-admin/ra-core-ee';

const BulkRestorePostsButton = () => {
    const { selectedIds } = useListContext();
    const [restoreMany, { isPending, error }] = useRestoreMany(
        { ids: selectedIds }
    );
    const handleClick = () => {
        restoreMany();
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Restore selected posts</button>;
};

// set params when calling the restoreMany callback
import { useListContext } from 'ra-core';
import { useRestoreMany } from '@react-admin/ra-core-ee';

const BulkRestorePostsButton = () => {
    const { selectedIds } = useListContext();
    const [restoreMany, { isPending, error }] = useRestoreMany();
    const handleClick = () => {
        restoreMany(
            { ids: seletedIds }
        );
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Restore selected posts</button>;
};
// set params when calling the hook
import { useListContext } from "ra-core";
import { useRestoreMany } from "@react-admin/ra-core-ee";

const BulkRestorePostsButton = () => {
    const { selectedIds } = useListContext();
    const [restoreMany, { isPending, error }] = useRestoreMany({ ids: selectedIds });
    const handleClick = () => {
        restoreMany();
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Restore selected posts
        </button>
    );
};

const BulkRestorePostsButton = () => {
    const { selectedIds } = useListContext();
    const [restoreMany, { isPending, error }] = useRestoreMany();
    const handleClick = () => {
        restoreMany({ ids: seletedIds });
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Restore selected posts
        </button>
    );
};
TypeScript

The useRestoreMany hook accepts a generic parameter for the record type and another for the error type:

useRestoreMany<Product, Error>(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type DeletedRecordType<Product>[]
        // TypeScript knows that error is of type Error
    },
});
useRestoreMany(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type DeletedRecordType<Product>[]
        // TypeScript knows that error is of type Error
    },
});

useHardDelete

This hook allows calling dataProvider.hardDelete() when the callback is executed and deleting a single deleted record based on its id.

Warning: The id here is the ID of the deleted record, and not the ID of the actual record that has been deleted.

const [hardDeleteOne, { data, isPending, error }] = useHardDelete(
    { id, previousData, meta },
    options,
);
const [hardDeleteOne, { data, isPending, error }] = useHardDelete({ id, previousData, meta }, options);

The hardDeleteOne() method can be called with the same parameters as the hook:

const [hardDeleteOne, { data, isPending, error }] = useHardDelete();

// ...

hardDeleteOne(
    { id, previousData, meta },
    options,
);
const [hardDeleteOne, { data, isPending, error }] = useHardDelete();

// ...

hardDeleteOne({ id, previousData, meta }, options);

So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the hardDeleteOne callback (second example).

Usage
// set params when calling the hook
import { useRecordContext } from 'ra-core';
import { useHardDelete } from '@react-admin/ra-core-ee';

const HardDeleteButton = () => {
    const deletedRecord = useRecordContext();
    const [hardDeleteOne, { isPending, error }] = useHardDelete(
        { id: deletedRecord.id, previousData: record }
    );
    const handleClick = () => {
        hardDeleteOne();
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete</button>;
};

// set params when calling the hardDeleteOne callback
import { useRecordContext } from 'ra-core';
import { useHardDelete } from '@react-admin/ra-core-ee';

const HardDeleteButton = () => {
    const deletedRecord = useRecordContext();
    const [hardDeleteOne, { isPending, error }] = useHardDelete();
    const handleClick = () => {
        hardDeleteOne(
            { id: deletedRecord.id, previousData: record }
        );
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete</button>;
};
// set params when calling the hook
import { useRecordContext } from "ra-core";
import { useHardDelete } from "@react-admin/ra-core-ee";

const HardDeleteButton = () => {
    const deletedRecord = useRecordContext();
    const [hardDeleteOne, { isPending, error }] = useHardDelete({ id: deletedRecord.id, previousData: record });
    const handleClick = () => {
        hardDeleteOne();
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete
        </button>
    );
};

const HardDeleteButton = () => {
    const deletedRecord = useRecordContext();
    const [hardDeleteOne, { isPending, error }] = useHardDelete();
    const handleClick = () => {
        hardDeleteOne({ id: deletedRecord.id, previousData: record });
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete
        </button>
    );
};
TypeScript

The useHardDelete hook accepts a generic parameter for the record type and another for the error type:

useHardDelete<Product, Error>(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type DeletedRecordType<Product>
        // TypeScript knows that error is of type Error
    },
});
useHardDelete(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type DeletedRecordType<Product>
        // TypeScript knows that error is of type Error
    },
});

useHardDeleteMany

This hook allows calling dataProvider.hardDeleteMany() when the callback is executed and deleting an array of deleted records based on their ids.

Warning: The ids here are the IDs of the deleted records, and not the IDs of the actual records that have been deleted.

const [hardDeleteMany, { data, isPending, error }] = useHardDeleteMany(
    { ids, meta },
    options,
);
const [hardDeleteMany, { data, isPending, error }] = useHardDeleteMany({ ids, meta }, options);

The hardDeleteMany() method can be called with the same parameters as the hook:

const [hardDeleteMany, { data, isPending, error }] = useHardDeleteMany();

// ...

hardDeleteMany(
    { ids, meta },
    options,
);
const [hardDeleteMany, { data, isPending, error }] = useHardDeleteMany();

// ...

hardDeleteMany({ ids, meta }, options);

So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the hardDeleteMany callback (second example).

Usage
// set params when calling the hook
import { useListContext } from 'ra-core';
import { useHardDeleteMany } from '@react-admin/ra-core-ee';

const BulkHardDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [hardDeleteMany, { isPending, error }] = useHardDeleteMany(
        { ids: selectedIds }
    );
    const handleClick = () => {
        hardDeleteMany();
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete selected posts</button>;
};

// set params when calling the hardDeleteMany callback
import { useListContext } from 'ra-core';
import { useHardDeleteMany } from '@react-admin/ra-core-ee';

const BulkHardDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [hardDeleteMany, { isPending, error }] = useHardDeleteMany();
    const handleClick = () => {
        hardDeleteMany(
            { ids: seletedIds }
        );
    }
    if (error) { return <p>ERROR</p>; }
    return <button disabled={isPending} onClick={handleClick}>Delete selected posts</button>;
};
// set params when calling the hook
import { useListContext } from "ra-core";
import { useHardDeleteMany } from "@react-admin/ra-core-ee";

const BulkHardDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [hardDeleteMany, { isPending, error }] = useHardDeleteMany({ ids: selectedIds });
    const handleClick = () => {
        hardDeleteMany();
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete selected posts
        </button>
    );
};

const BulkHardDeletePostsButton = () => {
    const { selectedIds } = useListContext();
    const [hardDeleteMany, { isPending, error }] = useHardDeleteMany();
    const handleClick = () => {
        hardDeleteMany({ ids: seletedIds });
    };
    if (error) {
        return <p>ERROR</p>;
    }
    return (
        <button disabled={isPending} onClick={handleClick}>
            Delete selected posts
        </button>
    );
};
TypeScript

The useHardDeleteMany hook accepts a generic parameter for the record type and another for the error type:

useHardDeleteMany<Product, Error>(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type Product['id'][]
        // TypeScript knows that error is of type Error
    },
});
useHardDeleteMany(undefined, undefined, {
    onError: (error) => {
        // TypeScript knows that error is of type Error
    },
    onSettled: (data, error) => {
        // TypeScript knows that data is of type Product['id'][]
        // TypeScript knows that error is of type Error
    },
});

useDeletedRecordsListController

useDeletedRecordsListController contains the headless logic to create a list of deleted records.

useDeletedRecordsListController reads the deleted records list parameters from the URL, calls dataProvider.getListDeleted(), prepares callbacks for modifying the pagination, filters, sort and selection, and returns them together with the data. Its return value matches the ListContext shape.

Usage

useDeletedRecordsListController expects a parameters object defining the deleted records list sorting, pagination, and filters. It returns an object with the fetched data, and callbacks to modify the deleted records list parameters.

You can call useDeletedRecordsListController() without parameters, and then put the result in a ListContext to make it available to the rest of the component tree.

import { ListContextProvider } from 'ra-core';
import { useDeletedRecordsListController } from '@react-admin/ra-core-ee';

const MyDeletedRecords = ({children}: { children: React.ReactNode }) => {
    const deletedRecordsListController = useDeletedRecordsListController();
    return (
        <ListContextProvider value={deletedRecordsListController}>
            {children}
        </ListContextProvider>
    );
};
import { ListContextProvider } from "ra-core";
import { useDeletedRecordsListController } from "@react-admin/ra-core-ee";

const MyDeletedRecords = ({ children }) => {
    const deletedRecordsListController = useDeletedRecordsListController();
    return <ListContextProvider value={deletedRecordsListController}>{children}</ListContextProvider>;
};
Parameters

useDeletedRecordsListController expects an object as parameter. All keys are optional.

  • debounce: Debounce time in ms for the setFilters callbacks.
  • disableAuthentication: Set to true to allow anonymous access to the list
  • disableSyncWithLocation: Set to true to have more than one list per page
  • filter: Permanent filter, forced over the user filter
  • filterDefaultValues: Default values for the filter form
  • perPage: Number of results per page
  • queryOptions: React-query options for the useQuery call.
  • resource: The resource of deleted records to fetch and display (used as filter when calling getListDeleted)
  • sort: Current sort value, e.g. { field: 'deleted_at', order: 'ASC' }
  • storeKey: Key used to differentiate the list from another, in store managed states

Here are their default values:

import { ListContextProvider } from 'ra-core';
import { useDeletedRecordsListController } from '@react-admin/ra-core-ee';

const CustomDeletedRecords = ({
    debounce = 500,
    disableAuthentication = false,
    disableSyncWithLocation = false,
    filter = undefined,
    filterDefaultValues = undefined,
    perPage = 10,
    queryOptions = undefined,
    sort = { field: 'deleted_at', order: 'DESC' },
    storeKey = undefined,
}) => {
    const deletedRecordsListController = useDeletedRecordsListController({
        debounce,
        disableAuthentication,
        disableSyncWithLocation,
        filter,
        filterDefaultValues,
        perPage,
        queryOptions,
        sort,
        storeKey,
    });
    return (
        <ListContextProvider value={deletedRecordsListController}>
            {children}
        </ListContextProvider>
    );
};
import { ListContextProvider } from "ra-core";
import { useDeletedRecordsListController } from "@react-admin/ra-core-ee";

const CustomDeletedRecords = ({
    debounce = 500,
    disableAuthentication = false,
    disableSyncWithLocation = false,
    filter = undefined,
    filterDefaultValues = undefined,
    perPage = 10,
    queryOptions = undefined,
    sort = { field: "deleted_at", order: "DESC" },
    storeKey = undefined,
}) => {
    const deletedRecordsListController = useDeletedRecordsListController({
        debounce,
        disableAuthentication,
        disableSyncWithLocation,
        filter,
        filterDefaultValues,
        perPage,
        queryOptions,
        sort,
        storeKey,
    });
    return <ListContextProvider value={deletedRecordsListController}>{children}</ListContextProvider>;
};
debounce

By default, useDeletedRecordsListController does not refresh the data as soon as the user enters data in the filter form. Instead, it waits for half a second of user inactivity (via lodash.debounce) before calling the dataProvider on filter change. This is to prevent repeated (and useless) calls to the API.

You can customize the debounce duration in milliseconds - or disable it completely - by passing a debounce parameter:

// wait 1 seconds instead of 500 milliseconds befoce calling the dataProvider
const deletedRecordsListController = useDeletedRecordsListController({ debounce: 1000 });
// wait 1 seconds instead of 500 milliseconds befoce calling the dataProvider
const deletedRecordsListController = useDeletedRecordsListController({ debounce: 1000 });
disableAuthentication

By default, useDeletedRecordsListController requires the user to be authenticated - any anonymous access redirects the user to the login page.

If you want to allow anonymous access to the deleted records list page, set the disableAuthentication parameter to true.

const anonymousDeletedRecordsListController = useDeletedRecordsListController({ disableAuthentication: true });
const anonymousDeletedRecordsListController = useDeletedRecordsListController({ disableAuthentication: true });
disableSyncWithLocation

By default, ra-core-ee synchronizes the useDeletedRecordsListController parameters (sort, pagination, filters) with the query string in the URL (using react-router location) and the Store.

You may want to disable this synchronization to keep the parameters in a local state, independent for each useDeletedRecordsListController call. To do so, pass the disableSyncWithLocation parameter. The drawback is that a hit on the "back" button doesn't restore the previous parameters.

const deletedRecordsListController = useDeletedRecordsListController({ disableSyncWithLocation: true });
const deletedRecordsListController = useDeletedRecordsListController({ disableSyncWithLocation: true });

Tip: disableSyncWithLocation also disables the persistence of the deleted records list parameters in the Store by default. To enable the persistence of the deleted records list parameters in the Store, you can pass a custom storeKey parameter.

const deletedRecordsListController = useDeletedRecordsListController({
    disableSyncWithLocation: true,
    storeKey: 'deletedRecordsListParams',
});
const deletedRecordsListController = useDeletedRecordsListController({
    disableSyncWithLocation: true,
    storeKey: "deletedRecordsListParams",
});
filter: Permanent Filter

You can choose to always filter the list, without letting the user disable this filter - for instance to display only published posts. Write the filter to be passed to the data provider in the filter parameter:

const deletedRecordsListController = useDeletedRecordsListController({
    filter: { deleted_by: 'test' },
});
const deletedRecordsListController = useDeletedRecordsListController({
    filter: { deleted_by: "test" },
});

The actual filter parameter sent to the data provider is the result of the combination of the user filters (the ones set through the filters component form), and the permanent filter. The user cannot override the permanent filters set by way of filter.

filterDefaultValues

To set default values to filters, you can pass an object literal as the filterDefaultValues parameter of useDeletedRecordsListController.

const deletedRecordsListController = useDeletedRecordsListController({
    filterDefaultValues: { deleted_by: 'test' },
});
const deletedRecordsListController = useDeletedRecordsListController({
    filterDefaultValues: { deleted_by: "test" },
});

Tip: The filter and filterDefaultValues props have one key difference: the filterDefaultValues can be overridden by the user, while the filter values are always sent to the data provider. Or, to put it otherwise:

const filterSentToDataProvider = { ...filterDefaultValues, ...filterChosenByUser, ...filter };
perPage

By default, the deleted records list paginates results by groups of 10. You can override this setting by specifying the perPage parameter:

const deletedRecordsListController = useDeletedRecordsListController({ perPage: 25 });
const deletedRecordsListController = useDeletedRecordsListController({ perPage: 25 });
queryOptions

useDeletedRecordsListController accepts a queryOptions parameter to pass query options to the react-query client. Check react-query's useQuery documentation for the list of available options.

This can be useful e.g. to pass a custom meta to the dataProvider.getListDeleted() call.

const deletedRecordsListController = useDeletedRecordsListController({
    queryOptions: { meta: { foo: 'bar' } },
});
const deletedRecordsListController = useDeletedRecordsListController({
    queryOptions: { meta: { foo: "bar" } },
});

With this option, ra-core-ee will call dataProvider.getListDeleted() on mount with the meta: { foo: 'bar' } option.

You can also use the queryOptions parameter to override the default error side effect. By default, when the dataProvider.getListDeleted() call fails, ra-core-ee shows an error notification. Here is how to show a custom notification instead:

import { useNotify, useRedirect } from 'ra-core';
import { useDeletedRecordsListController } from '@react-admin/ra-core-ee';

const CustomDeletedRecords = () => {
    const notify = useNotify();
    const redirect = useRedirect();

    const onError = (error) => {
        notify(`Could not load list: ${error.message}`, { type: 'error' });
        redirect('/dashboard');
    };

    const deletedRecordsListController = useDeletedRecordsListController({
        queryOptions: { onError },
    });

    return (
        <>{/* ... */}</>
    );
}
import { useNotify, useRedirect } from "ra-core";
import { useDeletedRecordsListController } from "@react-admin/ra-core-ee";

const CustomDeletedRecords = () => {
    const notify = useNotify();
    const redirect = useRedirect();

    const onError = (error) => {
        notify(`Could not load list: ${error.message}`, { type: "error" });
        redirect("/dashboard");
    };

    const deletedRecordsListController = useDeletedRecordsListController({
        queryOptions: { onError },
    });

    return <>{/* ... */}</>;
};

The onError function receives the error from the dataProvider call (dataProvider.getListDeleted()), which is a JavaScript Error object (see the dataProvider documentation for details).

resource

useDeletedRecordsListController fetches the deleted records from the data provider using the dataProvider.getListDeleted() method. When no resource is specified, it will fetch all deleted records from all resources and display a filter.

If you want to display only the deleted records of a specific resource, you can pass the resource parameter:

const deletedRecordsListController = useDeletedRecordsListController({ resource: 'posts' });
const deletedRecordsListController = useDeletedRecordsListController({ resource: "posts" });

The title is also updated accordingly. Its translation key is ra-soft-delete.deleted_records_list.resource_title.

sort

Pass an object literal as the sort parameter to determine the default field and order used for sorting:

const PessimisticDeletedRecords = () => (
    <DeletedRecordsList sort={{ field: 'id', order: 'ASC' }} />
);
const PessimisticDeletedRecords = () => <DeletedRecordsList sort={{ field: "id", order: "ASC" }} />;

sort defines the default sort order ; it can still be changed by using the setSort function returned by the controller.

For more details on list sort, see the Sorting The List section.

storeKey

To display multiple deleted records lists and keep distinct store states for each of them (filters, sorting and pagination), specify unique keys with the storeKey property.

In case no storeKey is provided, the states will be stored with the following key: ra-soft-delete.listParams.

Note: Please note that selection state will remain linked to a constant key (ra-soft-delete.selectedIds) as described here.

If you want to disable the storage of list parameters altogether for a given list, you can use the disableSyncWithLocation prop.

In the example below, the controller states of NewestDeletedRecords and OldestDeletedRecords are stored separately (under the store keys 'newest' and 'oldest' respectively).

import { useDeletedRecordsListController } from '@react-admin/ra-core-ee';

const OrderedDeletedRecords = ({
    storeKey,
    sort,
}) => {
    const params = useDeletedRecordsListController({
        sort,
        storeKey,
    });
    return (
        <ul>
            {!params.isPending &&
                params.data.map(deletedRecord => (
                    <li key={`deleted_record_${deletedRecord.id}`}>
                        [{deletedRecord.deleted_at}] Deleted by {deletedRecord.deleted_by}: <code>{JSON.stringify(deletedRecord.data)}</code>
                    </li>
                ))}
        </ul>
    );
};

const NewestDeletedRecords = (
    <OrderedDeletedRecords storeKey="newest" sort={{ field: 'deleted_at', order: 'DESC' }} />
);
const OldestDeletedRecords = (
    <OrderedDeletedRecords storeKey="oldest" sort={{ field: 'deleted_at', order: 'ASC' }} />
);
import { useDeletedRecordsListController } from "@react-admin/ra-core-ee";

const OrderedDeletedRecords = ({ storeKey, sort }) => {
    const params = useDeletedRecordsListController({
        sort,
        storeKey,
    });
    return (
        <ul>
            {!params.isPending &&
                params.data.map((deletedRecord) => (
                    <li key={`deleted_record_${deletedRecord.id}`}>
                        [{deletedRecord.deleted_at}] Deleted by {deletedRecord.deleted_by}:{" "}
                        <code>{JSON.stringify(deletedRecord.data)}</code>
                    </li>
                ))}
        </ul>
    );
};

const NewestDeletedRecords = <OrderedDeletedRecords storeKey="newest" sort={{ field: "deleted_at", order: "DESC" }} />;
const OldestDeletedRecords = <OrderedDeletedRecords storeKey="oldest" sort={{ field: "deleted_at", order: "ASC" }} />;

You can disable this feature by setting the storeKey prop to false. When disabled, parameters will not be persisted in the store.

Return value

useDeletedRecordsListController returns an object with the following keys:

const {
    // Data
    data, // Array of the deleted records, e.g. [{ id: 123, resource: 'posts', deleted_at: '2025-03-25T12:32:22Z', deleted_by: 'test', data: { ... } }, { ... }, ...]
    total, // Total number of deleted records for the current filters, excluding pagination. Useful to build the pagination controls, e.g. 23      
    isPending, // Boolean, true until the data is available
    isFetching, // Boolean, true while the data is being fetched, false once the data is fetched
    isLoading, // Boolean, true until the data is fetched for the first time
    // Pagination
    page, // Current page. Starts at 1
    perPage, // Number of results per page. Defaults to 25
    setPage, // Callback to change the page, e.g. setPage(3)
    setPerPage, // Callback to change the number of results per page, e.g. setPerPage(25)
    hasPreviousPage, // Boolean, true if the current page is not the first one
    hasNextPage, // Boolean, true if the current page is not the last one
    // Sorting
    sort, // Sort object { field, order }, e.g. { field: 'deleted_at', order: 'DESC' }
    setSort, // Callback to change the sort, e.g. setSort({ field: 'id', order: 'ASC' })
    // Filtering
    filterValues, // Dictionary of filter values, e.g. { resource: 'posts', deleted_by: 'test' }
    setFilters, // Callback to update the filters, e.g. setFilters(filters)
    // Record selection
    selectedIds, // Array listing the ids of the selected deleted records, e.g. [123, 456]
    onSelect, // Callback to change the list of selected deleted records, e.g. onSelect([456, 789])
    onToggleItem, // Callback to toggle the deleted record selection for a given id, e.g. onToggleItem(456)
    onUnselectItems, // Callback to clear the deleted records selection, e.g. onUnselectItems();
    // Misc
    defaultTitle, // Translated title, e.g. 'Archives'
    refetch, // Callback for fetching the deleted records again
} = useDeletedRecordsListController();
const {
    // Data
    data, // Array of the deleted records, e.g. [{ id: 123, resource: 'posts', deleted_at: '2025-03-25T12:32:22Z', deleted_by: 'test', data: { ... } }, { ... }, ...]
    total, // Total number of deleted records for the current filters, excluding pagination. Useful to build the pagination controls, e.g. 23
    isPending, // Boolean, true until the data is available
    isFetching, // Boolean, true while the data is being fetched, false once the data is fetched
    isLoading, // Boolean, true until the data is fetched for the first time
    // Pagination
    page, // Current page. Starts at 1
    perPage, // Number of results per page. Defaults to 25
    setPage, // Callback to change the page, e.g. setPage(3)
    setPerPage, // Callback to change the number of results per page, e.g. setPerPage(25)
    hasPreviousPage, // Boolean, true if the current page is not the first one
    hasNextPage, // Boolean, true if the current page is not the last one
    // Sorting
    sort, // Sort object { field, order }, e.g. { field: 'deleted_at', order: 'DESC' }
    setSort, // Callback to change the sort, e.g. setSort({ field: 'id', order: 'ASC' })
    // Filtering
    filterValues, // Dictionary of filter values, e.g. { resource: 'posts', deleted_by: 'test' }
    setFilters, // Callback to update the filters, e.g. setFilters(filters)
    // Record selection
    selectedIds, // Array listing the ids of the selected deleted records, e.g. [123, 456]
    onSelect, // Callback to change the list of selected deleted records, e.g. onSelect([456, 789])
    onToggleItem, // Callback to toggle the deleted record selection for a given id, e.g. onToggleItem(456)
    onUnselectItems, // Callback to clear the deleted records selection, e.g. onUnselectItems();
    // Misc
    defaultTitle, // Translated title, e.g. 'Archives'
    refetch, // Callback for fetching the deleted records again
} = useDeletedRecordsListController();
Security

useDeletedRecordsListController requires authentication and will redirect anonymous users to the login page. If you want to allow anonymous access, use the disableAuthentication property.

If your authProvider implements Access Control, useDeletedRecordsListController will only render if the user has the deleted_records access on a virtual ra-soft-delete resource.

For instance, for the <CustomDeletedRecords> page below:

import { useDeletedRecordsListController } from '@react-admin/ra-core-ee';

const CustomDeletedRecords = () => {
    const { isPending, error, data, total } = useDeletedRecordsListController({ filter: { resource: 'posts' } })
    if (error) return <div>Error!</div>;
    if (isPending) return <div>Loading...</div>;
    return (
        <ul>
            {data.map(deletedRecord => (
                <li key={deletedRecord.id}>
                    {deletedRecord.data.title} deleted by {deletedRecord.deleted_by}
                </li>
            ))}
        </ul>
    );
}
import { useDeletedRecordsListController } from "@react-admin/ra-core-ee";

const CustomDeletedRecords = () => {
    const { isPending, error, data, total } = useDeletedRecordsListController({ filter: { resource: "posts" } });
    if (error) return <div>Error!</div>;
    if (isPending) return <div>Loading...</div>;
    return (
        <ul>
            {data.map((deletedRecord) => (
                <li key={deletedRecord.id}>
                    {deletedRecord.data.title} deleted by {deletedRecord.deleted_by}
                </li>
            ))}
        </ul>
    );
};

useDeletedRecordsListController will call authProvider.canAccess() using the following parameters:

{ resource: 'ra-soft-delete', action: 'list_deleted_records' }
{
    resource: "ra-soft-delete", action;
    ("list_deleted_records");
}

Users without access will be redirected to the Access Denied page.

Note: Access control is disabled when you use the disableAuthentication property.

Components

<DeletedRecordsListBase>

The <DeletedRecordsListBase> component fetches a list of deleted records from the data provider.

Usage

<DeletedRecordsListBase> uses dataProvider.getListDeleted() to get the deleted records to display, so in general it doesn't need any property. However, you need to define the route to reach this component manually using <CustomRoutes>.

// in src/App.js
import { CoreAdmin, CustomRoutes } from 'ra-core';
import { Route } from 'react-router-dom';
import { DeletedRecordsListBase, DeletedRecordRepresentation } from '@react-admin/ra-core-ee';

export const App = () => (
    <CoreAdmin>
        ...
        <CustomRoutes>
            <Route
                path="/deleted"
                element={
                    <DeletedRecordsListBase>
                        <WithListContext
                            render={({ isPending, data }) => isPending ? null : (
                                <ul>
                                    {data.map(record => (
                                        <li key={record.id}>
                                            <div><strong>{record.resource}</strong></div>
                                            <DeletedRecordRepresentation record={record} />
                                        </li>
                                    ))}
                                </ul>
                            )}
                        />
                    </DeletedRecordsListBase>
                }
            />
        </CustomRoutes>
    </CoreAdmin>
);
// in src/App.js
import { CoreAdmin, CustomRoutes } from "ra-core";
import { Route } from "react-router-dom";
import { DeletedRecordsListBase, DeletedRecordRepresentation } from "@react-admin/ra-core-ee";

export const App = () => (
    <CoreAdmin>
        ...
        <CustomRoutes>
            <Route
                path="/deleted"
                element={
                    <DeletedRecordsListBase>
                        <WithListContext
                            render={({ isPending, data }) =>
                                isPending ? null : (
                                    <ul>
                                        {data.map((record) => (
                                            <li key={record.id}>
                                                <div>
                                                    <strong>{record.resource}</strong>
                                                </div>
                                                <DeletedRecordRepresentation record={record} />
                                            </li>
                                        ))}
                                    </ul>
                                )
                            }
                        />
                    </DeletedRecordsListBase>
                }
            />
        </CustomRoutes>
    </CoreAdmin>
);

That's enough to display the deleted records list, with functional simple filters, sort and pagination.

Props
Prop Required Type Default Description
children Required Element The component used to render the list of deleted records.
authLoading Optional ReactNode - The component to render while checking for authentication and permissions.
debounce Optional number 500 The debounce delay in milliseconds to apply when users change the sort or filter parameters.
disable Authentication Optional boolean false Set to true to disable the authentication check.
disable SyncWithLocation Optional boolean false Set to true to disable the synchronization of the list parameters with the URL.
empty Optional ReactNode - The component to display when the list is empty.
error Optional ReactNode - The component to render when failing to load the list of records.
filter Optional object - The permanent filter values.
filter DefaultValues Optional object - The default filter values.
loading Optional ReactNode - The component to render while loading the list of records.
offline Optional ReactNode The component to render when there is no connectivity and there is no data in the cache
perPage Optional number 10 The number of records to fetch per page.
queryOptions Optional object - The options to pass to the useQuery hook.
resource Optional string - The resource of deleted records to fetch and display
sort Optional object { field: 'deleted_at', order: 'DESC' } The initial sort parameters.
storeKey Optional string or false - The key to use to store the current filter & sort. Pass false to disable store synchronization
authLoading

By default, <DeletedRecordsListBase> renders its children while checking for authentication and permissions. You can display a custom component via the authLoading prop:

export const CustomDeletedRecords = () => (
    <DeletedRecordsListBase authLoading={<p>Checking for permissions...</p>} />
);
children

A component that uses ListContext to render the deleted records:

import { DeletedRecordsListBase, DeletedRecordRepresentation } from '@react-admin/ra-core-ee';

export const CustomDeletedRecords = () => (
    <DeletedRecordsListBase>
        <WithListContext
            render={({ isPending, data }) => isPending ? null : (
                <ul>
                    {data.map(record => (
                        <li key={record.id}>
                            <div><strong>{record.resource}</strong></div>
                            <DeletedRecordRepresentation record={record} /> 
                        </li>
                    ))}
                </ul>
            )}
        />
    </DeletedRecordsListBase>
);
import { DeletedRecordsListBase, DeletedRecordRepresentation } from "@react-admin/ra-core-ee";

export const CustomDeletedRecords = () => (
    <DeletedRecordsListBase>
        <WithListContext
            render={({ isPending, data }) =>
                isPending ? null : (
                    <ul>
                        {data.map((record) => (
                            <li key={record.id}>
                                <div>
                                    <strong>{record.resource}</strong>
                                </div>
                                <DeletedRecordRepresentation record={record} />
                            </li>
                        ))}
                    </ul>
                )
            }
        />
    </DeletedRecordsListBase>
);
debounce

By default, <DeletedRecordsListBase> does not refresh the data as soon as the user enters data in the filter form. Instead, it waits for half a second of user inactivity (via lodash.debounce) before calling the dataProvider on filter change. This is to prevent repeated (and useless) calls to the API.

You can customize the debounce duration in milliseconds - or disable it completely - by passing a debounce prop to the <DeletedRecordsListBase> component:

// wait 1 seconds instead of 500 milliseconds befoce calling the dataProvider
const DeletedRecordsWithDebounce = () => <DeletedRecordsListBase debounce={1000} />;
// wait 1 seconds instead of 500 milliseconds befoce calling the dataProvider
const DeletedRecordsWithDebounce = () => <DeletedRecordsListBase debounce={1000} />;
disableAuthentication

By default, <DeletedRecordsListBase> requires the user to be authenticated - any anonymous access redirects the user to the login page.

If you want to allow anonymous access to the deleted records list page, set the disableAuthentication prop to true.

const AnonymousDeletedRecords = () => <DeletedRecordsListBase disableAuthentication />;
const AnonymousDeletedRecords = () => <DeletedRecordsListBase disableAuthentication />;
disableSyncWithLocation

By default, react-admin synchronizes the <DeletedRecordsListBase> parameters (sort, pagination, filters) with the query string in the URL (using react-router location) and the Store.

You may want to disable this synchronization to keep the parameters in a local state, independent for each <DeletedRecordsListBase> instance. To do so, pass the disableSyncWithLocation prop. The drawback is that a hit on the "back" button doesn't restore the previous parameters.

const DeletedRecordsWithoutSyncWithLocation = () => <DeletedRecordsListBase disableSyncWithLocation />;
const DeletedRecordsWithoutSyncWithLocation = () => <DeletedRecordsListBase disableSyncWithLocation />;

Tip: disableSyncWithLocation also disables the persistence of the deleted records list parameters in the Store by default. To enable the persistence of the deleted records list parameters in the Store, you can pass a custom storeKey prop.

const DeletedRecordsSyncWithStore = () => <DeletedRecordsListBase disableSyncWithLocation storeKey="deletedRecordsListParams" />;
const DeletedRecordsSyncWithStore = () => (
    <DeletedRecordsListBase disableSyncWithLocation storeKey="deletedRecordsListParams" />
);
empty

By default, <DeletedRecordsListBase> renders the children when there are no deleted records to show. You can render a custom component via the empty prop:

export const CustomDeletedRecords = () => (
    <DeletedRecordsListBase empty={<p>The trash is empty!</p>} />
);
error

By default, <DeletedRecordsListBase> renders the children when an error happens while loading the list of deleted records. You can render an error component via the error prop:

export const CustomDeletedRecords = () => (
    <DeletedRecordsListBase error={<p>Something went wrong while loading your posts!</p>} />
);
filter: Permanent Filter

You can choose to always filter the list, without letting the user disable this filter - for instance to display only published posts. Write the filter to be passed to the data provider in the filter prop:

const DeletedPostsList = () => (
    <DeletedRecordsListBase filter={{ resource: 'posts' }} />
);
const DeletedPostsList = () => <DeletedRecordsListBase filter={{ resource: "posts" }} />;

The actual filter parameter sent to the data provider is the result of the combination of the user filters (the ones set through the filters component form), and the permanent filter. The user cannot override the permanent filters set by way of filter.

filterDefaultValues

To set default values to filters, you can pass an object literal as the filterDefaultValues prop of the <DeletedRecordsListBase> element.

const CustomDeletedRecords = () => (
    <DeletedRecordsListBase filterDefaultValues={{ resource: 'posts' }} />
);
const CustomDeletedRecords = () => <DeletedRecordsListBase filterDefaultValues={{ resource: "posts" }} />;

Tip: The filter and filterDefaultValues props have one key difference: the filterDefaultValues can be overridden by the user, while the filter values are always sent to the data provider. Or, to put it otherwise:

const filterSentToDataProvider = { ...filterDefaultValues, ...filterChosenByUser, ...filter };
loading

By default, <DeletedRecordsList> renders the children while loading the list of deleted records. You can display a component during this time via the loading prop:

export const CustomDeletedRecords = () => (
    <DeletedRecordsListBase loading={<p>Loading...</p>} />
);
offline

By default, <DeletedRecordsListBase> renders the children when there is no connectivity and there are no records in the cache yet for the current parameters (page, sort, etc.). You can provide your own component via the offline prop:

export const CustomDeletedRecords = () => (
    <DeletedRecordsListBase offline={<p>No network. Could not load the posts.</p>} />
);
perPage

By default, the deleted records list paginates results by groups of 10. You can override this setting by specifying the perPage prop:

const DeletedRecordsWithCustomPagination = () => <DeletedRecordsListBase perPage={25} />;
const DeletedRecordsWithCustomPagination = () => <DeletedRecordsListBase perPage={25} />;
queryOptions

<DeletedRecordsListBase> accepts a queryOptions prop to pass query options to the react-query client. Check react-query's useQuery documentation for the list of available options.

This can be useful e.g. to pass a custom meta to the dataProvider.getListDeleted() call.

const CustomDeletedRecords = () => (
    <DeletedRecordsListBase queryOptions={{ meta: { foo: 'bar' } }} />
);
const CustomDeletedRecords = () => <DeletedRecordsListBase queryOptions={{ meta: { foo: "bar" } }} />;

With this option, react-admin will call dataProvider.getListDeleted() on mount with the meta: { foo: 'bar' } option.

You can also use the queryOptions prop to override the default error side effect. By default, when the dataProvider.getListDeleted() call fails, react-admin shows an error notification. Here is how to show a custom notification instead:

import { useNotify, useRedirect } from 'ra-core';
import { DeletedRecordsListBase } from '@react-admin/ra-core-ee';

const CustomDeletedRecords = () => {
    const notify = useNotify();
    const redirect = useRedirect();

    const onError = (error) => {
        notify(`Could not load list: ${error.message}`, { type: 'error' });
        redirect('/dashboard');
    };

    return (
        <DeletedRecordsListBase queryOptions={{ onError }} />
    );
}
import { useNotify, useRedirect } from "ra-core";
import { DeletedRecordsListBase } from "@react-admin/ra-core-ee";

const CustomDeletedRecords = () => {
    const notify = useNotify();
    const redirect = useRedirect();

    const onError = (error) => {
        notify(`Could not load list: ${error.message}`, { type: "error" });
        redirect("/dashboard");
    };

    return <DeletedRecordsListBase queryOptions={{ onError }} />;
};

The onError function receives the error from the dataProvider call (dataProvider.getListDeleted()), which is a JavaScript Error object (see the dataProvider documentation for details).

resource

<DeletedRecordsListBase> fetches the deleted records from the data provider using the dataProvider.getListDeleted() method. When no resource is specified, it will fetch all deleted records from all resources and display a filter.

If you want to display only the deleted records of a specific resource, you can pass the resource prop:

const DeletedPosts = () => (
    <DeletedRecordsListBase resource="posts" />
);
const DeletedPosts = () => <DeletedRecordsListBase resource="posts" />;

When a resource is specified, the filter will not be displayed, and the list will only show deleted records of that resource.

The title is also updated accordingly. Its translation key is ra-soft-delete.deleted_records_list.resource_title.

sort

Pass an object literal as the sort prop to determine the default field and order used for sorting:

const PessimisticDeletedRecords = () => (
    <DeletedRecordsListBase sort={{ field: 'id', order: 'ASC' }} />
);
const PessimisticDeletedRecords = () => <DeletedRecordsListBase sort={{ field: "id", order: "ASC" }} />;

sort defines the default sort order ; the list remains sortable by clicking on column headers.

For more details on list sort, see the Sorting The List section.

storeKey

By default, react-admin stores the list parameters (sort, pagination, filters) in localStorage so that users can come back to the list and find it in the same state as when they left it. The <DeletedRecordsListBase> component uses a specific identifier to store the list parameters under the key ra-soft-delete.listParams.

If you want to use multiple <DeletedRecordsList> and keep distinct store states for each of them (filters, sorting and pagination), you must give each list a unique storeKey property. You can also disable the persistence of list parameters and selection in the store by setting the storeKey prop to false.

In the example below, the deleted records lists store their list parameters separately (under the store keys 'deletedBooks' and 'deletedAuthors'). This allows to use both components in the same app, each having its own state (filters, sorting and pagination).

import { CoreAdmin, CustomRoutes } from 'ra-core';
import { Route } from 'react-router-dom';
import { DeletedRecordsListBase } from '@react-admin/ra-core-ee';

const Admin = () => {
    return (
        <CoreAdmin dataProvider={dataProvider}>
            <CustomRoutes>
                <Route path="/books/deleted" element={
                    <DeletedRecordsListBase filter={{ resource: 'books' }} storeKey="deletedBooks" />
                } />
                <Route path="/authors/deleted" element={
                    <DeletedRecordsListBase filter={{ resource: 'authors' }} storeKey="deletedAuthors" />
                } />
            </CustomRoutes>
            <Resource name="books" />
        </CoreAdmin>
    );
};
import { CoreAdmin, CustomRoutes } from "ra-core";
import { Route } from "react-router-dom";
import { DeletedRecordsListBase } from "@react-admin/ra-core-ee";

const Admin = () => {
    return (
        <CoreAdmin dataProvider={dataProvider}>
            <CustomRoutes>
                <Route
                    path="/books/deleted"
                    element={<DeletedRecordsListBase filter={{ resource: "books" }} storeKey="deletedBooks" />}
                />
                <Route
                    path="/authors/deleted"
                    element={<DeletedRecordsListBase filter={{ resource: "authors" }} storeKey="deletedAuthors" />}
                />
            </CustomRoutes>
            <Resource name="books" />
        </CoreAdmin>
    );
};

Tip: The storeKey is actually passed to the underlying useDeletedRecordsListController hook, which you can use directly for more complex scenarios. See the useDeletedRecordsListController doc for more info.

Note: Selection state will remain linked to a global key regardless of the specified storeKey string. This is a design choice because if row selection is not stored globally, then when a user permanently deletes or restores a record it may remain selected without any ability to unselect it. If you want to allow custom storeKey's for managing selection state, you will have to implement your own useDeletedRecordsListController hook and pass a custom key to the useRecordSelection hook. You will then need to implement your own delete buttons to manually unselect rows when deleting or restoring records. You can still opt out of all store interactions including selection if you set it to false.

<ShowDeletedBase>

The <ShowDeletedBase> component replaces the <ShowBase> component when displaying a deleted record.

It provides the same ShowContext as <ShowBase> so that you can use the same children components.

import { CoreAdmin, CustomRoutes, WithRecord } from 'ra-core';
import { Route } from 'react-router-dom';
import { DeletedRecordsListBase, DeletedRecordRepresentation, ShowDeletedBase, type DeletedRecordType } from '@react-admin/ra-core-ee';

export const App = () => (
    <CoreAdmin>
        ...
        <CustomRoutes>
            <Route
                path="/deleted"
                element={
                    <DeletedRecordsListBase>
                        <WithListContext
                            render={({ isPending, data }) => isPending ? null : (
                                <ul>
                                    {data.map(record => (
                                        <li key={record.id}>
                                            <DeletedItem record={record} />
                                        </li>
                                    ))}
                                </ul>
                            )}
                        />
                    </DeletedRecordsListBase>
                }
            />
        </CustomRoutes>
    </CoreAdmin>
);

const DeletedItem = ({ record }: { record: DeletedRecordType }) => {
    const [showDetails, setShowDetails] = React.useState(false);
    return (
        <>
            <div><strong>{record.resource}</strong></div>
            <DeletedRecordRepresentation record={record} />
            <div>
                <button onClick={() => setShowDetails(true)}>Details</button>
            </div>
            {showDetails ? (
                <ShowDeletedBase record={record}>
                    <WithRecord render={record => <p>{record.title}</p>} />
                    <WithRecord render={record => <p>{record.description}</p>} />
                    <button onClick={() => setShowDetails(false)}>Close</button>
                </ShowDeletedBase>
            ) : null}
        </>
    )
}
import { CoreAdmin, CustomRoutes, WithRecord } from "ra-core";
import { Route } from "react-router-dom";
import { DeletedRecordsListBase, DeletedRecordRepresentation, ShowDeletedBase } from "@react-admin/ra-core-ee";

export const App = () => (
    <CoreAdmin>
        ...
        <CustomRoutes>
            <Route
                path="/deleted"
                element={
                    <DeletedRecordsListBase>
                        <WithListContext
                            render={({ isPending, data }) =>
                                isPending ? null : (
                                    <ul>
                                        {data.map((record) => (
                                            <li key={record.id}>
                                                <DeletedItem record={record} />
                                            </li>
                                        ))}
                                    </ul>
                                )
                            }
                        />
                    </DeletedRecordsListBase>
                }
            />
        </CustomRoutes>
    </CoreAdmin>
);

const DeletedItem = ({ record }) => {
    const [showDetails, setShowDetails] = React.useState(false);
    return (
        <>
            <div>
                <strong>{record.resource}</strong>
            </div>
            <DeletedRecordRepresentation record={record} />
            <div>
                <button onClick={() => setShowDetails(true)}>Details</button>
            </div>
            {showDetails ? (
                <ShowDeletedBase record={record}>
                    <WithRecord render={(record) => <p>{record.title}</p>} />
                    <WithRecord render={(record) => <p>{record.description}</p>} />
                    <button onClick={() => setShowDetails(false)}>Close</button>
                </ShowDeletedBase>
            ) : null}
        </>
    );
};
Props
Prop Required Type Default Description
children Required Element The component used to render the deleted record.
record Optional RaRecord The deleted record. If not provided, the record from closest RecordContext is used.

<DeletedRecordRepresentation>

A component that renders the record representation of a deleted record.

import { CoreAdmin, CustomRoutes, WithRecord } from 'react-admin';
import { Route } from 'react-router-dom';
import { DeletedRecordsListBase, ShowDeletedBase, type DeletedRecordType } from '@react-admin/ra-core-ee';

export const App = () => (
    <CoreAdmin>
        ...
        <CustomRoutes>
            <Route
                path="/deleted"
                element={
                    <DeletedRecordsListBase>
                        <WithListContext
                            render={({ isPending, data }) => isPending ? null : (
                                <ul>
                                    {data.map(record => (
                                        <li key={record.id}>
                                            <div><strong>{record.resource}</strong></div>
                                            <DeletedRecordRepresentation record={record} />
                                        </li>
                                    ))}
                                </ul>
                            )}
                        />
                    </DeletedRecordsListBase>
                }
            />
        </CustomRoutes>
    </CoreAdmin>
);
import { CoreAdmin, CustomRoutes } from "react-admin";
import { Route } from "react-router-dom";
import { DeletedRecordsListBase } from "@react-admin/ra-core-ee";

export const App = () => (
    <CoreAdmin>
        ...
        <CustomRoutes>
            <Route
                path="/deleted"
                element={
                    <DeletedRecordsListBase>
                        <WithListContext
                            render={({ isPending, data }) =>
                                isPending ? null : (
                                    <ul>
                                        {data.map((record) => (
                                            <li key={record.id}>
                                                <div>
                                                    <strong>{record.resource}</strong>
                                                </div>
                                                <DeletedRecordRepresentation record={record} />
                                            </li>
                                        ))}
                                    </ul>
                                )
                            }
                        />
                    </DeletedRecordsListBase>
                }
            />
        </CustomRoutes>
    </CoreAdmin>
);
Props
Prop Required Type Default Description
record Optional RaRecord The deleted record. If not provided, the record from closest RecordContext is used.

Relationships

Many-To-Many Relationships

Developers usually store many-to-many relationships in databases using an associative table (also known as join table, junction table or cross-reference table). For instance, if a Book can have many Authors, and an Author can write several Books, the normalized way to store this relationship in a relational database uses an intermediate table book_authors, as follows:

┌──────────────────┐       ┌──────────────┐      ┌───────────────┐
│ books            │       │ book_authors │      │ authors       │
│------------------│       │--------------│      │---------------│
│ id               │───┐   │ id           │   ┌──│ id            │
│ title            │   └──╼│ book_id      │   │  │ first_name    │
│ body             │       │ author_id    │╾──┘  │ last_name     │
│ publication_date │       │ is_public    │      │ date_of_birth │
└──────────────────┘       └──────────────┘      └───────────────┘

In the book_authors table, book_id and author_id are both foreign keys to books and authors.

A REST API closely following this data model exposes the three resources /books, /authors, and /book_authors. ra-relationships components rely on the associative table without ever showing it to the end-user. From the end user's point of view, the associative table is an implementation detail.

Out of scope

If the associative table uses a composite primary key, then ra-relationships does not work, as react-admin requires that all entities expose an identifier called id. For example, if user permissions are seen as a many-to-many relationship, they can be modeled in a relational database as follows:

users         user_permissions    permissions
----------    ----------------    -----------------
login         user_login          key
password      permission_key      description
first_name
last_name

Here, the associative table uses a composite primary key made of the tuple (user_login, permission_key). To allow react-admin to use this associative table, the related API route (/user_permissions) must include a unique id field for each record (which can simply be the concatenation of the two foreign keys).

Also, if your REST API can present that relationship through a list of related record ids (e.g. author_ids in books and book_ids in authors), you don't need ra-relationships. Just use <ReferenceArrayFieldBase> and <ReferenceArrayInputBase>, which are standard components in react-admin.

<ReferenceOneInputBase>

Use <ReferenceOneInputBase> in an <EditBase> or <CreateBase> view to edit one-to-one relationships, e.g. to edit the details of a book in the book edition view.

Usage

Here is an example one-to-one relationship: a book has at most one book_details row associated to it.

┌─────────────┐       ┌──────────────┐
│ book        │       │ book_details │
│-------------│       │--------------│
│ id          │───┐   │ id           │
│ title       │   └──╼│ book_id      │
└─────────────┘       │ year         │
                      │ author       │
                      │ country      │
                      │ genre        │
                      │ pages        │
                      └──────────────┘

You probably want to let users edit the book details directly from the book Edition view (instead of having to go to the book details Edition view). <ReferenceOneInputBase> allows to do that.

import { EditBase, Form } from 'ra-core';
import { TextInput, NumberInput } from 'my-react-admin-ui-library';
import { ReferenceOneInputBase } from '@react-admin/ra-core-ee';

const BookEdit = () => (
    <EditBase mutationMode="optimistic">
        <Form>
            <TextInput source="title" />
            <ReferenceOneInputBase reference="book_details" target="book_id">
                <NumberInput source="year" />
                <TextInput source="author" />
                <TextInput source="country" />
                <TextInput source="genre" />
                <NumberInput source="pages" />
            </ReferenceOneInputBase>
        </Form>
    </EditBase>
);
import { EditBase, Form } from "ra-core";
import { TextInput, NumberInput } from "my-react-admin-ui-library";
import { ReferenceOneInputBase } from "@react-admin/ra-core-ee";

const BookEdit = () => (
    <EditBase mutationMode="optimistic">
        <Form>
            <TextInput source="title" />
            <ReferenceOneInputBase reference="book_details" target="book_id">
                <NumberInput source="year" />
                <TextInput source="author" />
                <TextInput source="country" />
                <TextInput source="genre" />
                <NumberInput source="pages" />
            </ReferenceOneInputBase>
        </Form>
    </EditBase>
);

<ReferenceOneInputBase> requires a reference and a target prop to know which entity to fetch, and one or more inputs as its children to edit the related record.

<ReferenceOneInputBase> persists the changes in the referenced record (book details in the above example) after persisting the changes in the main resource (book in the above example). This means that you can also use <ReferenceOneInputBase> in Create views.

Tip: <ReferenceOneInputBase> does not support optimistic nor undoable mutations. You will need to set mutationMode="pessimistic" in the parent Edition component, as in the example above.

Props

Prop Required Type Default Description
target Required string - Target field carrying the relationship on the referenced resource, e.g. 'book_id'
reference Required string - The name of the resource for the referenced records, e.g. 'book_details'
children Optional Element - One or several input elements that accept a source prop
defaultValue Optional Object - Default value for the related record (in case it does not yet exist)
error Optional Element - The element to display when an error occurs while loading a reference
filter Optional Object - Filters to use when fetching the related record, passed to `getManyReference()
loading Optional Element - The element to display while loading a reference
mutationOptions Optional UseMutationOptions - Options for the mutations (create and update)
render Optional Function - A function that returns the children to display. Takes precedence over children
sort Optional { field, order } { field: 'id', order: 'ASC' } Sort order to use when fetching the related record, passed to `getManyReference()
source Optional string id Name of the field that carries the identity of the current record, used as origin for the relationship
queryOptions Optional UseQueryOptions - Options for the queries (getManyReferences)

children

<ReferenceOneInputBase> expects input components as its children, which will allow to edit the related record.

<ReferenceOneInputBase reference="book_details" target="book_id">
    <NumberInput source="year" />
    <TextInput source="author" />
    <TextInput source="country" />
    <TextInput source="genre" />
    <NumberInput source="pages" />
</ReferenceOneInputBase>

defaultValue

<ReferenceOneInputBase> allows to specify a default value for the related record. This is useful when the current record does not yet have a related record, and you want to pre-fill the related record with some default values.

<ReferenceOneInputBase
    reference="book_details"
    target="book_id"
    defaultValue={{ author: 'Gustave Flaubert', year: 1857 }}
>
    ...
</ReferenceOneInputBase>

filter

<ReferenceOneInputBase> allows to specify filters to use when fetching the related record. This can be useful when you need additional filters to select the related record.

<ReferenceOneInputBase
    reference="book_details"
    target="book_id"
    filter={{ reviewed: true }}
>
    ...
</ReferenceOneInputBase>

error

To display a custom element when an error occurs while loading the reference, use the error prop:

<ReferenceOneInputBase
    reference="book_details"
    target="book_id"
    error={<MyError />}
>
    ...
</ReferenceOneInputBase>

loading

To display a custom element while loading the reference, use the loading prop:

<ReferenceOneInputBase
    reference="book_details"
    target="book_id"
    error={<MySkeleton />}
>
    ...
</ReferenceOneInputBase>

mutationOptions

Use the mutationOptions prop to pass options to the dataProvider.create() and dataProvider.update() mutations.

For instance, to pass a custom meta:

<ReferenceOneInputBase
    reference="book_details"
    target="book_id"
    mutationOptions={{ meta: { foo: 'bar' } }}
>
    ...
</ReferenceOneInputBase>
<ReferenceOneInputBase reference="book_details" target="book_id" mutationOptions={{ meta: { foo: "bar" } }}>
    ...
</ReferenceOneInputBase>;

reference

The name of the resource to fetch for the related records.

For instance, if you want to display the book_details of a given book, the reference name should be book_details:

<ReferenceOneInputBase reference="book_details" target="book_id">
    ...
</ReferenceOneInputBase>

render

<ReferenceOneInputBase> accepts a render function instead of children if you want more control over what to display in all possible states.

<ReferenceOneInputBase
    reference="book_details"
    target="book_id" 
    render={({ isPending }) => isPending ? <Skeleton /> : (
        <>
            <NumberInput source="year" />
            <TextInput source="author" />
            <TextInput source="country" />
            <TextInput source="genre" />
            <NumberInput source="pages" />
        </>
    )}
/>

sort

<ReferenceOneInputBase> allows to specify the sort options used when fetching the related record. This can be useful when the relation table does not have an id column.

<ReferenceOneInputBase
    reference="book_details"
    target="book_id"
    sort={{ field: '_id', order: 'DESC' }}
>
    ...
</ReferenceOneInputBase>

source

By default, <ReferenceManyInputBase> fetches the reference for which the target field equals the current record id. You can customize the field that carries the identity of the current record by setting the source prop.

<ReferenceOneInputBase reference="book_details" target="book_id" source="_id">
    ...
</ReferenceOneInputBase>

target

Name of the field carrying the relationship on the referenced resource. For instance, if each book is linked to a record in book_details, and each book_details exposes a book_id field linking to the book, the target would be book_id.

<ReferenceOneInputBase reference="book_details" target="book_id">
    ...
</ReferenceOneInputBase>

queryOptions

Use the queryOptions prop to pass options to the dataProvider.getManyReferences() query that fetches the possible choices.

For instance, to pass a custom meta:

<ReferenceOneInputBase
    reference="book_details"
    target="book_id"
    queryOptions={{ meta: { foo: 'bar' } }}
>
    ...
</ReferenceOneInputBase>
<ReferenceOneInputBase reference="book_details" target="book_id" queryOptions={{ meta: { foo: "bar" } }}>
    ...
</ReferenceOneInputBase>;

Limitations

  • <ReferenceOneInputBase> cannot be used inside an <ArrayInput> or a <ReferenceManyInputBase>.
  • <ReferenceOneInputBase> cannot have a <ReferenceManyInputBase> or a <ReferenceManyToManyInputBase> as one of its children.
  • <ReferenceOneInputBase> does not support server side validation.
  • <ReferenceOneInputBase> does not support optimistic nor undoable mutations.

Changing An Item's Value Programmatically

You can leverage react-hook-form's setValue method to change the reference record's value programmatically.

However you need to know the name under which the inputs were registered in the form, and these names are dynamically generated by <ReferenceOneInputBase>.

To get the name of a specific input, you can leverage the SourceContext created by react-admin, which can be accessed using the useSourceContext hook.

This context provides a getSource function that returns the effective source for an input in the current context, which you can use as input name for setValue.

Here is an example where we leverage getSource and setValue to update some of the book details when the 'Update book details' button is clicked:

import { useSourceContext } from 'ra-core';
import { TextInput, NumberInput } from 'my-react-admin-ui-library';
import { ReferenceOneInputBase } from '@react-admin/ra-core-ee';
import { useFormContext } from 'react-hook-form';

const UpdateBookDetails = () => {
    const sourceContext = useSourceContext();
    const { setValue } = useFormContext();

    const onClick = () => {
        // Generate random values for year and pages
        const year = 1000 + Math.floor(Math.random() * 1000);
        const pages = 100 + Math.floor(Math.random() * 900);
        setValue(sourceContext.getSource('year'), year);
        setValue(sourceContext.getSource('pages'), pages);
    };

    return (
        <Button onClick={onClick} size="small" sx={{ maxWidth: 200 }}>
            Update book details
        </Button>
    );
};

const BookDetails = () => (
    <ReferenceOneInputBase
        reference="book_details"
        target="book_id"
        sort={sort}
        filter={filter}
    >
        <div>
            <NumberInput source="year" />
            <TextInput source="author" />
            <TextInput source="country" />
            <TextInput source="genre" />
            <NumberInput source="pages" />
            <UpdateBookDetails />
        </div>
    </ReferenceOneInputBase>
);
import { useSourceContext } from "ra-core";
import { TextInput, NumberInput } from "my-react-admin-ui-library";
import { ReferenceOneInputBase } from "@react-admin/ra-core-ee";
import { useFormContext } from "react-hook-form";

const UpdateBookDetails = () => {
    const sourceContext = useSourceContext();
    const { setValue } = useFormContext();

    const onClick = () => {
        // Generate random values for year and pages
        const year = 1000 + Math.floor(Math.random() * 1000);
        const pages = 100 + Math.floor(Math.random() * 900);
        setValue(sourceContext.getSource("year"), year);
        setValue(sourceContext.getSource("pages"), pages);
    };

    return (
        <Button onClick={onClick} size="small" sx={{ maxWidth: 200 }}>
            Update book details
        </Button>
    );
};

const BookDetails = () => (
    <ReferenceOneInputBase reference="book_details" target="book_id" sort={sort} filter={filter}>
        <div>
            <NumberInput source="year" />
            <TextInput source="author" />
            <TextInput source="country" />
            <TextInput source="genre" />
            <NumberInput source="pages" />
            <UpdateBookDetails />
        </div>
    </ReferenceOneInputBase>
);

<ReferenceManyInputBase>

Use <ReferenceManyInputBase> in an edition or creation views to edit one-to-many relationships, e.g. to edit the variants of a product in the product edition view.

<ReferenceManyInputBase> fetches the related records, and renders them in a sub-form. When users add, remove of update related records, the <ReferenceManyInputBase> component stores these changes locally. When the users actually submit the form, <ReferenceManyInputBase> computes a diff with the existing relationship, and sends the related changes (additions, deletions, and updates) to the server.

Usage

An example one-to-many relationship can be found in ecommerce systems: a product has many variants.

┌───────────────┐       ┌──────────────┐
│ products      │       │ variants     │
│---------------│       │--------------│
│ id            │───┐   │ id           │
│ name          │   └──╼│ product_id   │
│ price         │       │ sku          │
│ category_id   │       │ size         │
└───────────────┘       │ color        │
                        │ stock        │
                        └──────────────┘

You probably want to let users edit variants directly from the product Edition view (instead of having to go to the variant Edition view). <ReferenceManyInputBase> allows to do that.

import { EditBase, Form, ReferenceInputBase } from 'ra-core';
import {
    AutocompleteInput,
    TextInput,
    NumberInput,
    SelectInput,
    SimpleFormIterator
} from 'my-react-admin-ui-library';
import { ReferenceManyInputBase } from '@react-admin/ra-core-ee';

const ProductEdit = () => (
    <EditBase mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <NumberInput source="price" />
            <ReferenceInputBase source="category_id" reference="categories">
                <AutocompleteInput />
            </ReferenceInputBase>
            <ReferenceManyInputBase reference="variants" target="product_id">
                <SimpleFormIterator>
                    <TextInput source="sku" />
                    <SelectInput source="size" choices={sizes} />
                    <SelectInput source="color" choices={colors} />
                    <NumberInput source="stock" defaultValue={0} />
                </SimpleFormIterator>
            </ReferenceManyInputBase>
        </Form>
    </EditBase>
);

<ReferenceManyInputBase> requires a reference and a target prop to know which entity to fetch, and a child component (usually a <SimpleFormIterator>) to edit the relationship.

<ReferenceManyInputBase> persists the changes in the reference records (variants in the above example) after persisting the changes in the main resource (product in the above example). This means that you can also use <ReferenceManyInputBase> in <CreateBase> views.

Tip: <ReferenceManyInputBase> cannot be used with undoable mutations. You have to set mutationMode="optimistic" or mutationMode="pessimistic" in the parent <EditBase> or <CreateBase>, as in the example above.

Props

Prop Required Type Default Description
target Required string - Target field carrying the relationship on the referenced resource, e.g. 'user_id'
reference Required string - The name of the resource for the referenced records, e.g. 'books'
children Optional Element - One or several elements that render a list of records based on a ListContext
defaultValue Optional array - Default value of the input.
filter Optional Object - Filters to use when fetching the related records, passed to getManyReference()
mutationOptions Optional UseMutationOptions - Options for the mutations (create, update and delete)
perPage Optional number 25 Maximum number of referenced records to fetch
queryOptions Optional UseQueryOptions - Options for the queries (getManyReferences)
rankSource Optional string - Name of the field used to store the rank of each item. When defined, it enables reordering of the items.
sort Optional { field, order } { field: 'id', order: 'DESC' } Sort order to use when fetching the related records, passed to getManyReference()
source Optional string id Name of the field that carries the identity of the current record, used as origin for the relationship
validate Optional Function | array - Validation rules for the array. See the Validation Documentation for details.

children

<ReferenceManyInputBase> creates an ArrayInputContext, so it accepts the same type of children as <ArrayInput>: a Form iterator. React-admin bundles one such iterator: <SimpleFormIterator>. It renders one row for each related record, giving the user the ability to add, remove, or edit related records.

<ReferenceManyInputBase reference="variants" target="product_id">
    <SimpleFormIterator>
        <TextInput source="sku" />
        <SelectInput source="size" choices={sizes} />
        <SelectInput source="color" choices={colors} />
        <NumberInput source="stock" defaultValue={0} />
    </SimpleFormIterator>
</ReferenceManyInputBase>

Check out the <SimpleFormIterator> documentation for more details.

defaultValue

When the current record has no related records, <ReferenceManyInputBase> renders an empty list with an "Add" button to add related records.

You can use the defaultValue prop to populate the list of related records in that case. It must be an array of objects.

<ReferenceManyInputBase
    reference="variants"
    target="product_id"
    defaultValue={[
        { sku: 'SKU_1', size: 'S', color: 'black', stock: 0 },
        { sku: 'SKU_2', size: 'M', color: 'black', stock: 0 },
        { sku: 'SKU_3', size: 'L', color: 'black', stock: 0 },
        { sku: 'SKU_4', size: 'XL', color: 'black', stock: 0 },
    ]}
>
    <SimpleFormIterator>
        <TextInput source="sku" />
        <SelectInput source="size" choices={sizes} />
        <SelectInput source="color" choices={colors} />
        <NumberInput source="stock" defaultValue={0} />
    </SimpleFormIterator>
</ReferenceManyInputBase>

filter

You can filter the query used to populate the current values. Use the filter prop for that.

<ReferenceManyInputBase
    reference="variants"
    target="product_id"
    filter={{ is_published: true }}
>
    ...
</ReferenceManyInputBase>

perPage

By default, ra-core-ee restricts the possible values to 25 and displays no pagination control. You can change the limit by setting the perPage prop:

<ReferenceManyInputBase reference="variants" target="product_id" perPage={10}>
    ...
</ReferenceManyInputBase>

rankSource

If the Form iterator you use as ReferenceManyInputBase children (e.g. <SimpleFormIterator>) provides controls to reorder the items in the list and the related records have a numeric rank field, you can enable the reordering feature by setting the rankSource prop.

For example, if the variants have a rank field, you can set the rankSource prop like this:

<ReferenceManyInputBase
    reference="variants"
    target="product_id"
    rankSource="rank"
>
    <SimpleFormIterator>
        <TextInput source="sku" />
        <SelectInput source="size" choices={sizes} />
        <SelectInput source="color" choices={colors} />
        <NumberInput source="stock" defaultValue={0} />
    </SimpleFormIterator>
</ReferenceManyInputBase>

Now the variants will be ordered by rank, and whenever the user changes the order of the items, <ReferenceManyInputBase> will update the rank field of each item accordingly.

reference

The name of the resource to fetch for the related records.

For instance, if you want to display the variants of a given product, the reference name should be variants:

<ReferenceManyInputBase reference="books" target="author_id">
    ...
</ReferenceManyInputBase>

sort

By default, related records appear ordered by id desc. You can change this order by setting the sort prop (an object with field and order properties).

<ReferenceManyInputBase
  reference="variants"
  target="product_id"
  sort={{ field: 'sku', order: 'ASC' }}
>
   ...
</ReferenceManyInputBase>

source

By default, <ReferenceManyInputBase> fetches the references for which the target field equals the current record id. You can customize the field that carries the identity of the current record by setting the source prop.

<ReferenceManyInputBase reference="variants" target="product_id" source="_id">
    ...
</ReferenceManyInputBase>

target

Name of the field carrying the relationship on the referenced resource. For instance, if a product has many variants, and each variant resource exposes an product_id field, the target would be author_id.

<ReferenceManyInputBase reference="variants" target="product_id">
    ...
</ReferenceManyInputBase>

validate

Just like regular inputs, you can use the validate prop to define custom validation rules for the list of references.

import { minLength } from 'ra-core';

const ProductEdit = () => (
    <EditBase mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <ReferenceInput source="category_id" reference="categories" />
            <ReferenceManyInputBase
                reference="variants"
                target="product_id"
                validate={[minLength(2, 'Please add at least 2 variants')]}
            >
                ...
            </ReferenceManyInputBase>
        </Form>
    </EditBase>
);

Limitations

  • <ReferenceManyInputBase> cannot be used inside an <ArrayInputBase> or a <ReferenceOneInputBase>.
  • <ReferenceManyInputBase> cannot be used with undoable mutations in a <Create> view.
  • <ReferenceManyInputBase> cannot have a <ReferenceOneInputBase> or a <ReferenceManyToManyInputBase> as one of its children.
  • <ReferenceManyInputBase> does not support server side validation.

Changing An Item's Value Programmatically

You can leverage react-hook-form's setValue method to change an item's value programmatically.

However you need to know the name under which the input was registered in the form, and this name is dynamically generated depending on the index of the item in the array.

To get the name of the input for a given index, you can leverage the SourceContext created by react-admin, which can be accessed using the useSourceContext hook.

This context provides a getSource function that returns the effective source for an input in the current context, which you can use as input name for setValue.

Here is an example where we leverage getSource and setValue to prefill the email input when the 'Prefill email' button is clicked:

import { useSourceContext } from 'ra-core';
import { SimpleFormIterator, TextInput } from 'my-react-admin-ui-library';
import { ReferenceManyInputBase } from '@react-admin/ra-core-ee';
import { useFormContext } from 'react-hook-form';

const PrefillEmail = () => {
    const sourceContext = useSourceContext();
    const { setValue, getValues } = useFormContext();

    const onClick = () => {
        const firstName = getValues(sourceContext.getSource('first_name'));
        const lastName = getValues(sourceContext.getSource('last_name'));
        const email = `${
            firstName ? firstName.toLowerCase() : ''
        }.${lastName ? lastName.toLowerCase() : ''}@school.com`;
        setValue(sourceContext.getSource('email'), email);
    };

    return (
        <button onClick={onClick}>
            Prefill email
        </button>
    );
};

const StudentsInput = () => (
    <ReferenceManyInputBase
        reference="students"
        target="teacher_id"
        sort={{ field: 'last_name', order: 'ASC' }}
    >
        <SimpleFormIterator>
            <TextInput source="first_name" helperText={false} />
            <TextInput source="last_name" helperText={false} />
            <TextInput source="email" helperText={false} />
            <PrefillEmail />
        </SimpleFormIterator>
    </ReferenceManyInputBase>
);
import { useSourceContext } from "ra-core";
import { SimpleFormIterator, TextInput } from "my-react-admin-ui-library";
import { ReferenceManyInputBase } from "@react-admin/ra-core-ee";
import { useFormContext } from "react-hook-form";

const PrefillEmail = () => {
    const sourceContext = useSourceContext();
    const { setValue, getValues } = useFormContext();

    const onClick = () => {
        const firstName = getValues(sourceContext.getSource("first_name"));
        const lastName = getValues(sourceContext.getSource("last_name"));
        const email = `${firstName ? firstName.toLowerCase() : ""}.${
            lastName ? lastName.toLowerCase() : ""
        }@school.com`;
        setValue(sourceContext.getSource("email"), email);
    };

    return <button onClick={onClick}>Prefill email</button>;
};

const StudentsInput = () => (
    <ReferenceManyInputBase reference="students" target="teacher_id" sort={{ field: "last_name", order: "ASC" }}>
        <SimpleFormIterator>
            <TextInput source="first_name" helperText={false} />
            <TextInput source="last_name" helperText={false} />
            <TextInput source="email" helperText={false} />
            <PrefillEmail />
        </SimpleFormIterator>
    </ReferenceManyInputBase>
);

Tip: If you only need the item's index, you can leverage the useSimpleFormIteratorItem hook instead.

<ReferenceManyToManyFieldBase>

This component fetches a list of referenced records by lookup in an associative table and passes the records down to its child component, which must be an iterator component.

Note: The <ReferenceManyToManyFieldBase> cannot currently display multiple records with the same id from the end reference resource, even though they might have different properties in the associative table.

Usage

Let's imagine that you're writing an app managing concerts for artists. The data model features a many-to-many relationship between the bands and venues tables through a performances associative table.

┌─────────┐       ┌──────────────┐      ┌───────────────┐
│ bands   │       │ performances │      │ venues        │
│---------│       │--------------│      │---------------│
│ id      │───┐   │ id           │   ┌──│ id            │
│ name    │   └──╼│ band_id      │   │  │ name          │
│         │       │ venue_id     │╾──┘  │ location      │
│         │       │ date         │      │               │
└─────────┘       └──────────────┘      └───────────────┘

In this example, bands.id matches performances.band_id, and performances.venue_id matches venues.id.

To allow users see the venues for a given band in <SingleFieldList>, wrap that component in <ReferenceManyToManyFieldBase> where you define the relationship via the reference, through and using props:

import React from 'react';
import { ShowBase } from 'ra-core';
import {
    TextField,
    DateField,
    SingleFieldList,
    ChipField,
} from 'my-react-admin-ui-library';
import { ReferenceManyToManyFieldBase } from '@react-admin/ra-core-ee';

export const BandShow = () => (
    <ShowBase>
        <div>
            <TextField source="name" />
            <ReferenceManyToManyFieldBase
                reference="venues"
                through="performances"
                using="band_id,venue_id"
                label="Performances"
            >
                <SingleFieldList>
                    <ChipField source="name" />
                </SingleFieldList>
            </ReferenceManyToManyFieldBase>
            <EditButton />
        </div>
    </ShowBase>
);
import React from "react";
import { ShowBase } from "ra-core";
import { TextField, SingleFieldList, ChipField } from "my-react-admin-ui-library";
import { ReferenceManyToManyFieldBase } from "@react-admin/ra-core-ee";

export const BandShow = () => (
    <ShowBase>
        <div>
            <TextField source="name" />
            <ReferenceManyToManyFieldBase
                reference="venues"
                through="performances"
                using="band_id,venue_id"
                label="Performances"
            >
                <SingleFieldList>
                    <ChipField source="name" />
                </SingleFieldList>
            </ReferenceManyToManyFieldBase>
            <EditButton />
        </div>
    </ShowBase>
);

Props

Prop Required Type Default Description
children Required element - An iterator element (e.g. <WithListContext>). The iterator element usually has one or more child <Field> components.
reference Required string - Name of the reference resource, e.g. 'venues'
through Required string - Name of the resource for the associative table, e.g. 'performances'
filter Optional object {} Filter for the associative table (passed to the getManyReference() call)
joinLimit Optional number 100 Limit for the number of results fetched from the associative table. Should be greater than perPage
perPage Optional number 25 Limit the number of displayed result after getManyReference is called. Useful when using a pagination component. Should be smaller than joinLimit
queryOptions Optional UseQueryOptions - Query options for the getMany and getManyReference calls
sort Optional { field: string, order: 'ASC' or 'DESC' } { field: 'id', order: 'DESC' } Sort for the associative table (passed to the getManyReference() call)
source Optional string 'id' Name of the field containing the identity of the main resource. Used determine the value to look for in the associative table.
using Optional string '[resource]_id,[reference]_id' Tuple (comma separated) of the two field names used as foreign keys, e.g 'band_id,venue_id'. The tuple should start with the field pointing to the resource, and finish with the field pointing to the reference

children

<ReferenceManyToManyFieldBase> expects an iterator component as child, i.e. a component working inside a ListContext.

import React from 'react';
import { ShowBase, WithListContext } from 'ra-core';
import { ReferenceManyToManyFieldBase } from '@react-admin/ra-core-ee';

export const BandShow = () => (
    <ShowBase>
        <div>
            <ReferenceManyToManyFieldBase
                reference="venues"
                through="performances"
                using="band_id,venue_id"
                label="Performances"
            >
                <WithListContext render={({ isPending, data }) => (
                        isPending ? (
                            <div>
                                {data.map(tag => (
                                    <span key={tag.id} label={tag.name} />
                                ))}
                            </div>
                        ) : null
                    )}
                />
            </ReferenceManyToManyFieldBase>
        </div>
    </ShowBase>
);
import React from "react";
import { ShowBase, WithListContext } from "ra-core";
import { ReferenceManyToManyFieldBase } from "@react-admin/ra-core-ee";

export const BandShow = () => (
    <ShowBase>
        <div>
            <ReferenceManyToManyFieldBase
                reference="venues"
                through="performances"
                using="band_id,venue_id"
                label="Performances"
            >
                <WithListContext
                    render={({ isPending, data }) =>
                        isPending ? (
                            <div>
                                {data.map((tag) => (
                                    <span key={tag.id} label={tag.name} />
                                ))}
                            </div>
                        ) : null
                    }
                />
            </ReferenceManyToManyFieldBase>
        </div>
    </ShowBase>
);

filter

You can filter the records of the associative table (e.g. performances) using the filter prop. This filter is passed to the getManyReference() call.

<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    filter={{ date: '2018-08-31' }}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    filter={{ date: "2018-08-31" }}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>;

joinLimit

By default, <ReferenceManyToManyFieldBase> fetches 100 entries in the join table (e.g. performances). You can decrease or increase the number of entries fetched from the associative table by modifying the joinLimit prop:

import { Pagination } from 'my-react-admin-ui-library';

<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    joinLimit={50}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>;
<ReferenceManyToManyFieldBase reference="venues" through="performances" using="band_id,venue_id" joinLimit={50}>
    {/* ... */}
</ReferenceManyToManyFieldBase>;
export {};

perPage

By default, <ReferenceManyToManyFieldBase> displays at most 25 entries from the associative table (e.g. 25 performances). You can change the limit by setting the perPage prop:

<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    perPage={10}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase reference="venues" through="performances" using="band_id,venue_id" perPage={10}>
    {/* ... */}
</ReferenceManyToManyFieldBase>;

queryOptions

Use the queryOptions prop to customize the queries for getMany and getManyReference.

You can for instance use it to pass a custom meta to the dataProvider.

<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    queryOptions={{ meta: { myParameter: 'value' } }}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    queryOptions={{ meta: { myParameter: "value" } }}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>;

reference

The name of the target resource to fetch.

For instance, if you want to display the venues of a given bands, through performances, the reference name should be venues:

<ReferenceManyToManyFieldBase
    source="id"
    reference="venues"
    resource="bands"
    through="performances"
>
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase source="id" reference="venues" resource="bands" through="performances">
    {/* ... */}
</ReferenceManyToManyFieldBase>;

sort

By default, <ReferenceManyToManyFieldBase> orders the possible values by id desc for the associative table (e.g. performances). You can change this order by setting the sort prop (an object with field and order properties) to be applied to the associative resource.

<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    sort={{ field: 'id', order: 'DESC' }}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    sort={{ field: "id", order: "DESC" }}
>
    {/* ... */}
</ReferenceManyToManyFieldBase>;

source

By default, <ReferenceManyToManyFieldBase> uses the id field as target for the reference. If the foreign key points to another field of your record, you can select it with the source prop

<ReferenceManyToManyFieldBase
    source="_id"
    reference="venues"
    resource="bands"
    through="performances"
>
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase source="_id" reference="venues" resource="bands" through="performances">
    {/* ... */}
</ReferenceManyToManyFieldBase>;

through

You must specify the associative table name using the through prop.

<ReferenceManyToManyFieldBase reference="venues" through="performances">
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase reference="venues" through="performances">
    {/* ... */}
</ReferenceManyToManyFieldBase>;

using

You can specify the columns to use in the associative using the using prop.

<ReferenceManyToManyFieldBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
>
    {/* ... */}
</ReferenceManyToManyFieldBase>
<ReferenceManyToManyFieldBase reference="venues" through="performances" using="band_id,venue_id">
    {/* ... */}
</ReferenceManyToManyFieldBase>;

DataProvider Calls

<ReferenceManyToManyFieldBase> fetches the dataProvider twice in a row:

  • once to get the records of the associative resource (performances in this case), using a getManyReference() call
  • once to get the records of the reference resource (venues in this case), using a getMany() call.

For instance, if the user displays the band of id 123, <ReferenceManyToManyFieldBase> first issues the following query to the dataProvider:

dataProvider.getManyReference('performances', {
    target: 'band_id',
    id: 123,
});

Let's say that the dataProvider returns the following response:

{
    "data": [
        { "id": 667, "band_id": 123, "venue_id": 732 },
        { "id": 895, "band_id": 123, "venue_id": 874 }
        { "id": 901, "band_id": 123, "venue_id": 756 }
    ],
    "total": 3
}

Then, <ReferenceManyToManyFieldBase> issues a second query to the dataProvider:

dataProvider.getMany('venues', {
    ids: [732, 874, 756],
});

And receives the reference venues:

{
    "data": [
        { "id": 732, "name": "Madison Square Garden" },
        { "id": 874, "name": "Yankee Stadium" }
        { "id": 874, "name": "Barclays Center" }
    ],
    "total": 3
}

<ReferenceManyToManyInputBase>

This component allows adding or removing relationships between two resources sharing an associative table. The changes in the associative table are sent to the dataProvider when the user submits the form so that they can cancel the changes before submission.

Note: The <ReferenceManyToManyInputBase> cannot currently display multiple records with the same id from the end reference resource even though they might have different properties in the associative table.

Usage

Let's imagine that you're writing an app managing concerts for artists. The data model features a many-to-many relationship between the bands and venues tables through a performances associative table.

┌─────────┐       ┌──────────────┐      ┌───────────────┐
│ bands   │       │ performances │      │ venues        │
│---------│       │--------------│      │---------------│
│ id      │───┐   │ id           │   ┌──│ id            │
│ name    │   └──╼│ band_id      │   │  │ name          │
│         │       │ venue_id     │╾──┘  │ location      │
│         │       │ date         │      │               │
└─────────┘       └──────────────┘      └───────────────┘

In this example, bands.id matches performances.band_id, and performances.venue_id matches venues.id.

To let users edit the venues for given band in an <AutocompleteArrayInput>, wrap that input in a <ReferenceManyToManyInputBase> where you define the relationship via the reference, through and using props:

import { EditBase, Form } from 'ra-core';
import { AutocompleteArrayInput, TextInput } from 'my-react-admin-ui-library';
import { ReferenceManyToManyInputBase } from '@react-admin/ra-core-ee';

export const BandEdit = () => (
    <EditBase mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <ReferenceManyToManyInputBase
                reference="venues"
                through="performances"
                using="band_id,venue_id"
            >
                <AutocompleteArrayInput
                    label="Performances"
                    optionText="name"
                />
            </ReferenceManyToManyInputBase>
        </Form>
    </EditBase>
);
import { EditBase, Form } from "ra-core";
import { AutocompleteArrayInput, TextInput } from "my-react-admin-ui-library";
import { ReferenceManyToManyInputBase } from "@react-admin/ra-core-ee";

export const BandEdit = () => (
    <EditBase mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <ReferenceManyToManyInputBase reference="venues" through="performances" using="band_id,venue_id">
                <AutocompleteArrayInput label="Performances" optionText="name" />
            </ReferenceManyToManyInputBase>
        </Form>
    </EditBase>
);

<ReferenceManyToManyInputBase> expects a child that is an input allowing to select multiple values as child - like <AutocompleteArrayInput> in the example above.

Note that although all possible child components support a defaultValue prop, it will only be applied on create views.

Tip: We don't recommend using <ReferenceManyToManyInputBase> in an edition view that has its mutationMode set to undoable. Indeed, even if users cancel the main mutation, the changes in the associative table will still be applied.

Tip: If you need to edit the fields of the associative table (e.g. the date in performances), you can use a <ReferenceManyInputBase> instead of <ReferenceManyToManyInputBase>.

Screenshot showing the use of ReferenceManyInput instead of ReferenceManyToManyInput

You will need to let users select the related record (venue in the example above) via a <ReferenceInputBase>:

import { EditBase, Form, ReferenceInputBase, required } from 'ra-core';
import {
    AutocompleteArrayInput,
    DateInput,
    SelectInput,
    SimpleFormIterator,
    TextInput,
} from 'my-react-admin-ui-library';
import { ReferenceManyInputBase } from '@react-admin/ra-core-ee';

const BandEdit = () => (
    <Edit mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <ReferenceManyInputBase reference="performances" target="band_id">
                <SimpleFormIterator inline disableReordering>
                    <DateInput source="date" />
                    <ReferenceInputBase reference="venues" source="venue_id">
                        <SelectInput optionText="name" />
                    </ReferenceInputBase>
                </SimpleFormIterator>
            </ReferenceManyInputBase>
        </Form>
    </Edit>
);
import { Form, ReferenceInputBase } from "ra-core";
import { DateInput, SelectInput, SimpleFormIterator, TextInput } from "my-react-admin-ui-library";
import { ReferenceManyInputBase } from "@react-admin/ra-core-ee";

const BandEdit = () => (
    <Edit mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <ReferenceManyInputBase reference="performances" target="band_id">
                <SimpleFormIterator inline disableReordering>
                    <DateInput source="date" />
                    <ReferenceInputBase reference="venues" source="venue_id">
                        <SelectInput optionText="name" />
                    </ReferenceInputBase>
                </SimpleFormIterator>
            </ReferenceManyInputBase>
        </Form>
    </Edit>
);

Limitation: <ReferenceManyToManyInputBase> cannot be used to filter a list.

Props

Prop Required Type Default Description
children Required element - A select array input element (e.g. <SelectArrayInput>).
reference Required string - Name of the reference resource, e.g. 'venues'
through Required string - Name of the resource for the associative table, e.g. 'book_authors'
filter Optional object {} Filter for the associative table (passed to the getManyReference() call)
filter Choices Optional object {} Filter for the possible choices fetched from the reference table (passed to the getList() call)
mutationOptions Optional { meta, onError } - Mutation options for the create and deleteMany calls. Only meta and onError are supported.
perPage Optional number 25 Limit for the number of results fetched from the associative table
perPage Choices Optional number 25 Limit for the number of possible choices fetched from the reference table
queryOptions Optional UseQueryOptions - Query options for the getList, getMany and getManyReference calls
sort Optional { field: string, order: 'ASC' or 'DESC' } { field: 'id', order: 'DESC' } Sort for the associative table (passed to the getManyReference() call)
sort Choices Optional { field: string, order: 'ASC' or 'DESC' } { field: 'id', order: 'DESC' } Sort for the possible choices fetched from the reference table (passed to the getList() call)
source Optional string 'id' Name of the field containing the identity of the main resource. Used determine the value to look for in the associative table.
using Optional string '([resource]_id,[reference]_id)' Tuple (comma separated) of the two field names used as foreign keys, e.g 'book_id,author_id'. The tuple should start with the field pointing to the resource, and finish with the field pointing to the reference

children

<ReferenceManyToManyInputBase> expects an select component as child, i.e. a component working inside a ChoiceContext.

import { EditBase, Form, ReferenceInputBase, required } from 'ra-core';
import {
    SelectArrayInput,
    DateInput,
    SelectInput,
    SimpleFormIterator,
    TextInput,
} from 'my-react-admin-ui-library';
import { ReferenceManyToManyInputBase } from '@react-admin/ra-core-ee';

const BandEdit = () => (
    <Edit mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <ReferenceManyToManyInputBase
                reference="venues"
                through="performances"
                using="band_id,venue_id"
                filter={{ date: '2018-08-31' }}
            >
                <SelectArrayInput />
            </ReferenceManyToManyInputBase>
        </Form>
    </Edit>
);
import { Form } from "ra-core";
import { SelectArrayInput, TextInput } from "my-react-admin-ui-library";
import { ReferenceManyToManyInputBase } from "@react-admin/ra-core-ee";

const BandEdit = () => (
    <Edit mutationMode="optimistic">
        <Form>
            <TextInput source="name" />
            <ReferenceManyToManyInputBase
                reference="venues"
                through="performances"
                using="band_id,venue_id"
                filter={{ date: "2018-08-31" }}
            >
                <SelectArrayInput />
            </ReferenceManyToManyInputBase>
        </Form>
    </Edit>
);

filter

You can filter the records of the associative table (e.g. performances) using the filter prop. This filter is passed to the getManyReference() call.

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    filter={{ date: '2018-08-31' }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    filter={{ date: "2018-08-31" }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>;

filterChoices

<ReferenceManyToManyInputBase> displays a list of possible values from the reference table (e.g. venues) as suggestions in the input. It uses the getList() dataProvider call to fetch these possible values.

You can filter the possible values of the reference table using the filterChoices prop. This filterChoices is passed to the getList() call.

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    filterChoice={{ location: 'New York' }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    filterChoice={{ location: "New York" }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>;

mutationOptions

Use the mutationOptions prop to customize the create and deleteMany mutations.

You can for instance use it to pass a custom meta to the dataProvider.

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    mutationOptions={{ meta: { myParameter: 'value' } }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    mutationOptions={{ meta: { myParameter: "value" } }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>;

You can also use it to pass an onError function as follows:

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    mutationOptions={{ 
        onError: (error, step, data) => console.warn({ error, step, data })
    }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    mutationOptions={{
        onError: (error, step, data) => console.warn({ error, step, data }),
    }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>;

perPage

By default, <ReferenceManyToManyInputBase> displays at most 25 entries from the associative table (e.g. 25 performances). You can change the limit by setting the perPage prop:

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    perPage={10}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase reference="venues" through="performances" using="band_id,venue_id" perPage={10}>
    {/* ... */}
</ReferenceManyToManyInputBase>;

perPageChoices

<ReferenceManyToManyInputBase> displays a list of possible values from the reference table (e.g. venues) as suggestions in the input. It uses the getList() dataProvider call to fetch these possible values.

By default, react-admin displays at most 25 possible values from the reference table (e.g. 25 venues). You can change the limit by setting the perPageChoices prop:

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    perPageChoices={10}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase reference="venues" through="performances" using="band_id,venue_id" perPageChoices={10}>
    {/* ... */}
</ReferenceManyToManyInputBase>;

queryOptions

Use the queryOptions prop to customize the queries for getList, getMany and getManyReference.

You can for instance use it to pass a custom meta to the dataProvider.

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    queryOptions={{ meta: { myParameter: 'value' } }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    queryOptions={{ meta: { myParameter: "value" } }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>;

reference

The name of the target resource to fetch.

For instance, if you want to display the venues of a given bands, through performances, the reference name should be venues:

<ReferenceManyToManyInputBase
    source="id"
    reference="venues"
    resource="bands"
    through="performances"
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase source="id" reference="venues" resource="bands" through="performances">
    {/* ... */}
</ReferenceManyToManyInputBase>;

sort

By default, <ReferenceManyToManyInputBase> orders the possible values by id desc for the associative table (e.g. performances). You can change this order by setting the sort prop (an object with field and order properties) to be applied to the associative resource.

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    sort={{ field: 'id', order: 'DESC' }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    sort={{ field: "id", order: "DESC" }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>;

sortChoices

By default, <ReferenceManyToManyInputBase> orders the possible values by id desc for the reference table (e.g. venues). You can change this order by setting the sortChoices prop (an object with field and order properties).

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    sortChoices={{ field: 'id', order: 'DESC' }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
    sortChoices={{ field: "id", order: "DESC" }}
>
    {/* ... */}
</ReferenceManyToManyInputBase>;

source

By default, <ReferenceManyToManyInputBase> uses the id field as target for the reference. If the foreign key points to another field of your record, you can select it with the source prop:

<ReferenceManyToManyInputBase
    source="_id"
    reference="venues"
    resource="bands"
    through="performances"
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase source="_id" reference="venues" resource="bands" through="performances">
    {/* ... */}
</ReferenceManyToManyInputBase>;

through

You must specify the associative table name using the through prop.

<ReferenceManyToManyInputBase reference="venues" through="performances">
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase reference="venues" through="performances">
    {/* ... */}
</ReferenceManyToManyInputBase>;

using

You can specify the columns to use in the associative using the using prop.

<ReferenceManyToManyInputBase
    reference="venues"
    through="performances"
    using="band_id,venue_id"
>
    {/* ... */}
</ReferenceManyToManyInputBase>
<ReferenceManyToManyInputBase reference="venues" through="performances" using="band_id,venue_id">
    {/* ... */}
</ReferenceManyToManyInputBase>;

Limitations

  • <ReferenceManyToManyInputBase> cannot be used inside an <ArrayInputBase>, a <ReferenceOneInputBase> or a <ReferenceManyInputBase>.
  • <ReferenceManyToManyInputBase> does not support server side validation.

dataProvider Calls

When rendered, <ReferenceManyToManyInputBase> fetches the dataProvider three times in a row:

  • once to get the records of the associative resource (performances in this case), using a getManyReference() call
  • once to get the records of the reference resource (venues in this case), using a getMany() call.
  • once to get the possible values of the reference resource (venues in this case) to show as suggestions in the input, using a getList() call

For instance, if the user edits the band of id 123, <ReferenceManyToManyInputBase> first issues the following query to the dataProvider:

dataProvider.getManyReference('venues', {
    target: 'band_id',
    id: 123,
});

Let's say that the dataProvider returns the following response:

{
    "data": [
        { "id": 667, "band_id": 123, "venue_id": 732 },
        { "id": 895, "band_id": 123, "venue_id": 874 }
        { "id": 901, "band_id": 123, "venue_id": 756 }
    ],
    "total": 3
}

Then, <ReferenceManyToManyInputBase> issues a second query to the dataProvider:

dataProvider.getMany('venues', {
    ids: [732, 874, 756],
});

Which returns the following:

{
    "data": [
        { "id": 732, "name": "Madison Square Garden" },
        { "id": 874, "name": "Yankee Stadium" }
        { "id": 874, "name": "Barclays Center" }
    ]
}

That's enough to display the current value in the input. But to display venues suggestions, the component makes a final call:

dataProvider.getList('venues', {
    sort: { field: 'id', order: 'DESC' },
    pagination: { page: 1, perPage: 25 },
    filter: {},
});
{
    "data": [
        { "id": 732, "name": "Madison Square Garden" },
        { "id": 874, "name": "Yankee Stadium" }
        { "id": 874, "name": "Barclays Center" }
        ...
    ],
    "total": 32
}

And that's it for the display phase.

When the user submits the form, the save function compares the value of the <ReferenceManyToManyInputBase> (the list of relationships edited by the user) with the value previously returned by the dataProvider. Using a diffing algorithm, it deduces a list of insertions and deletions in the associative table, that are executed all at once.

For instance, let's say that after displaying the venues 732 and 874 where bands 123 performs, the user removes venue 732, and adds venues 2 and 3. Upon submission, the dataProvider will detect removals and additions, and send the following queries:

dataProvider.delete('performances', {
    id: 667,
    previousData: { id: 667, band_id: 123, venue_id: 732 },
});
dataProvider.create('performances', {
    data: { band_id: 123, venue_id: 2 },
});
dataProvider.create('performances', {
    data: { band_id: 123, venue_id: 3 },
});

I18N

This module uses specific translations for displaying notifications. As for all translations in react-admin, it's possible to customize the messages.

To create your own translations, you can use the TypeScript types to see the structure and see which keys are overridable.

Here is an example of how to customize translations in your app:

import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import {
    raRelationshipsLanguageEnglish,
    raRelationshipsLanguageFrench,
    type RaRelationshipsTranslationMessages,
} from '@react-admin/ra-core-ee';
import { CoreAdmin, mergeTranslations, type TranslationMessages as BaseTranslationMessages } from 'ra-core';

/* TranslationMessages extends the default translation
 * Type from ra-core (BaseTranslationMessages)
 * and the ra-Relationships translation Type (RaRelationshipsTranslationMessages)
 */
interface TranslationMessages
    extends RaRelationshipsTranslationMessages,
        BaseTranslationMessages {}

const customEnglishMessages: TranslationMessages = mergeTranslations(
    englishMessages,
    raRelationshipsLanguageEnglish, 
    {
        'ra-relationships': {
            referenceManyToManyInput: {
                saveError: 'Server error: your changes were not completely saved',
            },
        },
    }
);

const i18nCustomProvider = polyglotI18nProvider(locale => {
    if (locale === 'fr') {
        return mergeTranslations(frenchMessages, raRealTimeLanguageFrench);
    }
    return customEnglishMessages;
}, 'en');

export const MyApp = () => (
    <CoreAdmin i18nProvider={i18nCustomProvider}>
        ...
    </CoreAdmin>
);
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";
import { raRelationshipsLanguageEnglish } from "@react-admin/ra-core-ee";
import { CoreAdmin, mergeTranslations } from "ra-core";

const customEnglishMessages = mergeTranslations(englishMessages, raRelationshipsLanguageEnglish, {
    "ra-relationships": {
        referenceManyToManyInput: {
            saveError: "Server error: your changes were not completely saved",
        },
    },
});

const i18nCustomProvider = polyglotI18nProvider((locale) => {
    if (locale === "fr") {
        return mergeTranslations(frenchMessages, raRealTimeLanguageFrench);
    }
    return customEnglishMessages;
}, "en");

export const MyApp = () => <CoreAdmin i18nProvider={i18nCustomProvider}>...</CoreAdmin>;

CHANGELOG

v1.3.0

2025-10-09

  • Import headless hooks and functions from ra-soft-delete

v1.2.0

2025-09-30

  • Import headless hooks from ra-relationships

v1.1.0

2025-09-19

  • Import headless hooks and functions from ra-realtime

v1.0.0

2025-09-08

  • Import headless hooks from ra-rbac