ra-realtime

Teams where several people work in parallel on a common task need to allow real-time notifications, and prevent data loss when two editors work on the same resource concurrently. ra-realtime provides hooks and UI components to lock records, live update views when a change occurs in the background, and notify end users of these events.

Test it live in the Enterprise Edition Storybook and in the e-commerce demo.

Installation

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

Tip: ra-realtime 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.

The package contains new translation messages (in English and French). You should add them to your i18nProvider. For instance, to add English messages:

import { Admin } from 'react-admin';
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-realtime';

const messages = {
    en: { ...englishMessages, ...raRealTimeLanguageEnglish },
    fr: { ...frenchMessages, ...raRealTimeLanguageFrench },
};

const i18nProvider = polyglotI18nProvider(locale => messages[locale], 'en');

const App = () => <Admin i18nProvider={i18nProvider}>{/* ... */}</Admin>;
import { Admin } from "react-admin";
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-realtime";

const messages = {
    en: { ...englishMessages, ...raRealTimeLanguageEnglish },
    fr: { ...frenchMessages, ...raRealTimeLanguageFrench },
};

const i18nProvider = polyglotI18nProvider((locale) => messages[locale], "en");

const App = () => <Admin i18nProvider={i18nProvider}>{/* ... */}</Admin>;

Live Updates

Ra-realtime provides live updates via alternative components to <List>, <Edit>, and <Show>. Just use the components from @react-admin/ra-realtime instead of their react-admin counterpart.

RealTimeList

For instance, replace <List> with <RealTimeList> to have a list refreshing automatically when an element is added, updated, or deleted:

import {
-   List,
    ListProps,
    Datagrid,
    TextField,
    NumberField,
    Datefield,
} from 'react-admin';
+import { RealTimeList } from '@react-admin/ra-realtime';

const PostList = (props: ListProps) => (
-   <List {...props}>
+   <RealTimeList {...props}>
        <Datagrid>
            <TextField source="title" />
            <NumberField source="views" />
            <DateField source="published_at" />
        </Datagrid>
-   </List>
+   </RealTimeList>
);

Check the <RealTimeList>, <RealTimeEdit>, and <RealTimeShow> components for details.

Ra-realtime also provides badge notifications in the Menu, so that users can see that something new happened to a resource list while working on another one.

RealTimeMenu

Use <RealTimeMenu> instead of react-admin's <Menu> to get this feature:

import React from 'react';
import { Admin, Layout, LayoutProps, Resource } from 'react-admin';
import { RealTimeMenu } from '@react-admin/ra-realtime';
import { PostList, PostShow, PostEdit, realTimeDataProvider } from '.';

const CustomLayout = (props: LayoutProps) => (
    <Layout {...props} menu={RealTimeMenu} />
);

const MyReactAdmin = () => (
    <Admin dataProvider={realTimeDataProvider} layout={CustomLayout}>
        <Resource
            name="posts"
            list={PostList}
            show={PostShow}
            edit={PostEdit}
        />
    </Admin>
);
import React from "react";
import { Admin, Layout, Resource } from "react-admin";
import { RealTimeMenu } from "@react-admin/ra-realtime";
import { PostList, PostShow, PostEdit, realTimeDataProvider } from ".";

const CustomLayout = (props) => <Layout {...props} menu={RealTimeMenu} />;

const MyReactAdmin = () => (
    <Admin dataProvider={realTimeDataProvider} layout={CustomLayout}>
        <Resource name="posts" list={PostList} show={PostShow} edit={PostEdit} />
    </Admin>
);

Locks

And last but not least, ra-realtime provides a lock mechanism to prevent editing or deleting the same record as another user.

This feature leverages specialized hooks, like useLockRecord:

import {
    useLockRecord,
    useGetLock,
    useGetRecordId,
} from '@react-admin/ra-realtime';
import {
    Edit,
    SimpleForm,
    TextInput,
    useResourceContext,
    Toolbar,
    SaveButton,
} from 'react-admin';
import { CircularProgress } from '@mui/material';
import React from 'react';

const MyLockedEditView = () => {
    const { isLoading } = useLockRecord();

    if (isLoading) {
        return <CircularProgress />;
    }

    return (
        <Edit>
            <SimpleForm toolbar={<CustomToolbar />}>
                <TextInput source="title" />
            </SimpleForm>
        </Edit>
    );
};

const CustomToolbar = () => {
    const resource = useResourceContext();
    const id = useGetRecordId();
    const { data } = useGetLock(resource, { id });

    const isMarioLocker = data?.identity === 'mario';

    // Prevent clicking on the `<SaveButton>` if someone else is locking this record
    return (
        <Toolbar>
            <SaveButton disabled={!isMarioLocker} />
        </Toolbar>
    );
};
import { useLockRecord, useGetLock, useGetRecordId } from "@react-admin/ra-realtime";
import { Edit, SimpleForm, TextInput, useResourceContext, Toolbar, SaveButton } from "react-admin";
import { CircularProgress } from "@mui/material";
import React from "react";

const MyLockedEditView = () => {
    const { isLoading } = useLockRecord();

    if (isLoading) {
        return <CircularProgress />;
    }

    return (
        <Edit>
            <SimpleForm toolbar={<CustomToolbar />}>
                <TextInput source="title" />
            </SimpleForm>
        </Edit>
    );
};

const CustomToolbar = () => {
    const resource = useResourceContext();
    const id = useGetRecordId();
    const { data } = useGetLock(resource, { id });

    const isMarioLocker = data?.identity === "mario";

    // Prevent clicking on the `<SaveButton>` if someone else is locking this record
    return (
        <Toolbar>
            <SaveButton disabled={!isMarioLocker} />
        </Toolbar>
    );
};

DataProvider For Real-Time Events

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

  • subscribe(topic, callback)
  • unsubscribe(topic, callback)
  • publish(topic, event)

These methods should return a 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 })

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 { Datagrid, EditButton, ListProps } from 'react-admin';
import {
    HydraAdmin,
    ResourceGuesser,
    FieldGuesser,
    hydraDataProvider,
} from '@api-platform/admin';
import {
    RealTimeList,
    addRealTimeMethodsBasedOnApiPlatform,
} from '@react-admin/ra-realtime';

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

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

// Example for connecting a list of greetings
const GreetingsList = () => (
    <RealTimeList>
        <Datagrid>
            <FieldGuesser source="name" />
            <EditButton />
        </Datagrid>
    </RealTimeList>
);
import { Datagrid, EditButton } from "react-admin";
import { HydraAdmin, ResourceGuesser, FieldGuesser, hydraDataProvider } from "@api-platform/admin";
import { RealTimeList, addRealTimeMethodsBasedOnApiPlatform } from "@react-admin/ra-realtime";

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

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

// Example for connecting a list of greetings
const GreetingsList = () => (
    <RealTimeList>
        <Datagrid>
            <FieldGuesser source="name" />
            <EditButton />
        </Datagrid>
    </RealTimeList>
);

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-realtime';

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 = () => (
    <Admin dataProvider={realTimeDataProvider}>{/* ... */}</Admin>
);
import { addRealTimeMethodsBasedOnMercure } from "@react-admin/ra-realtime";

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 = () => <Admin dataProvider={realTimeDataProvider}>{/* ... */}</Admin>;

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('ra-realtime.error.topic'));
        }
        if (!event.type) {
            return Promise.reject(new Error('ra-realtime.error.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("ra-realtime.error.topic"));
        }
        if (!event.type) {
            return Promise.reject(new Error("ra-realtime.error.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.

Lock Support

The 4 data provider lock methods (lock, unlock, getLock, and getLocks) accept the following parameters:

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

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

GET /locks?sort=["id","ASC"]&range=[0, 1]&filter={"resource":"people","recordId":"18"}
POST /locks

The POST query should contain the following body:

{
    "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 { Admin } from 'react-admin';
import { addLocksMethodsBasedOnALockResource } from '@react-admin/ra-realtime';

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

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

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

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

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.

Publishers send an event to all the subscribers. An event should be a JavaScript object with a type and a payload field. In addition, ra-realtime requires that every event contains a topic - the name of the topic it's published to.

Here is an example event:

{
    topic: 'messages',
    type: 'created',
    payload: 'New message',
    date: new Date(),
}

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 resources and records. To enable this integration, your real-time backend should publish the following events:

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

Calling the dataProvider Methods Directly

Once you've set a real-time dataProvider in your <Admin>, 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 'react-admin';

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 "react-admin";

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 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 'react-admin';

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

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

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

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

Tip: You should not need to call dataProvider.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.

useSubscribe

In practice, every component that should react to an event needs a useEffect calling dataProvider.subscribe(), and returning a callback calling dataProvider.unsubscribe(). That's why ra-realtime exposes a useSubscribe hook, which simplifies that logic a great deal.

Here is the same <MessageList> as above, but using useSubscribe:

import React, { useState } from 'react';
import { useNotify } from 'react-admin';
import { useSubscribe } from '@react-admin/ra-realtime';

const MessageList = () => {
    const notify = useNotify();
    const [messages, setMessages] = useState([]);
    useSubscribe('messages', event => {
        setMessages([...messages, event.payload]);
        notify('New message');
    });
    return (
        <ul>
            {messages.map((message, index) => (
                <li key={index}>{message}</li>
            ))}
        </ul>
    );
};
import React, { useState } from "react";
import { useNotify } from "react-admin";
import { useSubscribe } from "@react-admin/ra-realtime";

const MessageList = () => {
    const notify = useNotify();
    const [messages, setMessages] = useState([]);
    useSubscribe("messages", (event) => {
        setMessages([...messages, event.payload]);
        notify("New message");
    });
    return (
        <ul>
            {messages.map((message, index) => (
                <li key={index}>{message}</li>
            ))}
        </ul>
    );
};

useSubscribeToRecord And useSubscribeToRecordList

Ra-realtime provides specialized versions of useSubscribe, to subscribe to events concerning:

  • a single record: useSubscribeToRecord(callback, resource, id)
  • a list of records: useSubscribeToRecordList(callback, resource)

Thanks to react-admin v4 providing resource and record contexts, the resource and id params can be omitted (if we are in such contexts), hence simplifying the code to:

  • useSubscribeToRecord(callback)
  • useSubscribeToRecordList(callback)

Using these hooks, you can add real-time capabilities to a <Show> view for instance:

import { Show, useNotify, useRefresh } from 'react-admin';
import { useSubscribeToRecord } from '@react-admin/ra-realtime';

const PostShow = () => {
    const notify = useNotify();
    const refresh = useRefresh();
    useSubscribeToRecord(event => {
        switch (event.type) {
            case 'updated': {
                refresh();
                notify('Record updated server-side');
                break;
            }
            case 'deleted': {
                notify('Record deleted server-side', 'warning');
                break;
            }
            default: {
                console.log('Unsupported event type', event);
            }
        }
    });
    return <Show>{/* ... */}</Show>;
};
import { Show, useNotify, useRefresh } from "react-admin";
import { useSubscribeToRecord } from "@react-admin/ra-realtime";

const PostShow = () => {
    const notify = useNotify();
    const refresh = useRefresh();
    useSubscribeToRecord((event) => {
        switch (event.type) {
            case "updated": {
                refresh();
                notify("Record updated server-side");
                break;
            }
            case "deleted": {
                notify("Record deleted server-side", "warning");
                break;
            }
            default: {
                console.log("Unsupported event type", event);
            }
        }
    });
    return <Show>{/* ... */}</Show>;
};

<RealTimeList>

<RealTimeList> refreshes the page when a record is created, updated, or deleted.

import React from 'react';
import { Datagrid, TextField } from 'react-admin';
import { RealTimeList } from '@react-admin/ra-realtime';

const PostList = () => (
    <RealTimeList>
        <Datagrid>
            <TextField source="title" />
        </Datagrid>
    </RealTimeList>
);
import React from "react";
import { Datagrid, TextField } from "react-admin";
import { RealTimeList } from "@react-admin/ra-realtime";

const PostList = () => (
    <RealTimeList>
        <Datagrid>
            <TextField source="title" />
        </Datagrid>
    </RealTimeList>
);

RealTimeList

To trigger refreshes of <RealTimeList>, the API has to publish events containing at least the followings:

  • topic: '/resource/{resource}';
    
  •   data : {
          topic : '/resource/{resource}',
          type: '{deleted || created || updated}',
          payload: { ids: [{listOfRecordIdentifiers}]},
      }
    

The <RealTimeList> allows you to customize the side effects triggered when it receives a new event, by passing a function to the onEventReceived prop:

import { Datagrid, TextField, useNotify, useRefresh } from 'react-admin';
import { RealTimeList } from '@react-admin/ra-realtime';

const PostList = () => {
    const notify = useNotify();
    const refresh = useRefresh();

    const handleEventReceived = event => {
        const count = get(event, 'payload.ids.length', 1);
        notify('ra-realtime.notification.list.refreshed', 'info', { count });
        refresh();
    };

    return (
        <RealTimeList onEventReceived={handleEventReceived}>
            <Datagrid>
                <TextField source="title" />
            </Datagrid>
        </RealTimeList>
    );
};
import { Datagrid, TextField, useNotify, useRefresh } from "react-admin";
import { RealTimeList } from "@react-admin/ra-realtime";

const PostList = () => {
    const notify = useNotify();
    const refresh = useRefresh();

    const handleEventReceived = (event) => {
        const count = get(event, "payload.ids.length", 1);
        notify("ra-realtime.notification.list.refreshed", "info", { count });
        refresh();
    };

    return (
        <RealTimeList onEventReceived={handleEventReceived}>
            <Datagrid>
                <TextField source="title" />
            </Datagrid>
        </RealTimeList>
    );
};

<RealTimeEdit>

<RealTimeEdit> displays a warning when the record is updated by another user and offers to refresh the page. Also, it displays a warning when the record is deleted by another user.

import React from 'react';
import { SimpleForm, TextInput } from 'react-admin';
import { RealTimeEdit } from '@react-admin/ra-realtime';

const PostEdit = props => (
    <RealTimeEdit>
        <SimpleForm>
            <TextInput source="title" />
        </SimpleForm>
    </RealTimeEdit>
);
import React from "react";
import { SimpleForm, TextInput } from "react-admin";
import { RealTimeEdit } from "@react-admin/ra-realtime";

const PostEdit = (props) => (
    <RealTimeEdit>
        <SimpleForm>
            <TextInput source="title" />
        </SimpleForm>
    </RealTimeEdit>
);

RealTimeEdit

To trigger <RealTimeEdit> features, the API has to publish events containing at least the following:

  • topic: '/resource/{resource}/{recordIdentifier}';
    
  •   data : {
          topic : '/resource/{resource}/{recordIdentifier}',
          type: '{deleted || updated}',
          payload: { id: [{recordIdentifier}]},
      }
    

The <RealTimeEdit> allows you to customize the side effects triggered when it receives a new event, by passing a function to the onEventReceived prop:

import React from 'react';
import { SimpleForm, TextInput, useRefresh } from 'react-admin';
import { RealTimeEdit } from '@react-admin/ra-realtime';

const PostEdit = () => {
    const notify = useNotify();

    const handleEventReceived = (
        event,
        { setDeleted, setUpdated, setUpdatedDisplayed }
    ) => {
        if (event.type === EventType.Updated) {
            notify('ra-realtime.notification.record.updated', 'info');
            setUpdated(true);
            setUpdatedDisplayed(true);
        } else if (event.type === EventType.Deleted) {
            notify('ra-realtime.notification.record.deleted', 'info');
            setDeleted(true);
            setUpdated(false);
            setUpdatedDisplayed(true);
        }
    };

    return (
        <RealTimeEdit onEventReceived={handleEventReceived}>
            <SimpleForm>
                <TextInput source="title" />
            </SimpleForm>
        </RealTimeEdit>
    );
};
import React from "react";
import { SimpleForm, TextInput } from "react-admin";
import { RealTimeEdit } from "@react-admin/ra-realtime";

const PostEdit = () => {
    const notify = useNotify();

    const handleEventReceived = (event, { setDeleted, setUpdated, setUpdatedDisplayed }) => {
        if (event.type === EventType.Updated) {
            notify("ra-realtime.notification.record.updated", "info");
            setUpdated(true);
            setUpdatedDisplayed(true);
        } else if (event.type === EventType.Deleted) {
            notify("ra-realtime.notification.record.deleted", "info");
            setDeleted(true);
            setUpdated(false);
            setUpdatedDisplayed(true);
        }
    };

    return (
        <RealTimeEdit onEventReceived={handleEventReceived}>
            <SimpleForm>
                <TextInput source="title" />
            </SimpleForm>
        </RealTimeEdit>
    );
};

The function passed to onEventReceived will be called with the event as its first argument and an object containing functions that will update the UI:

  • setDeleted: If set to true, the edit view will show a message to let users know this record has been deleted.
  • setUpdated: If set to true, the edit view will show a message to let users know this record has been updated.
  • setUpdatedDisplayed: Must be set to true after calling setUpdated. This is used to show the message about the record being updated only for a few seconds.

<RealTimeShow>

<RealTimeShow> shows a notification and refreshes the page when the record is updated by another user. Also, it displays a warning when the record is deleted by another user.

import React from 'react';
import { SimpleShowLayout, TextField } from 'react-admin';
import { RealTimeShow } from '@react-admin/ra-realtime';

const PostShow = () => (
    <RealTimeShow>
        <SimpleShowLayout>
            <TextField source="title" />
        </SimpleShowLayout>
    </RealTimeShow>
);
import React from "react";
import { SimpleShowLayout, TextField } from "react-admin";
import { RealTimeShow } from "@react-admin/ra-realtime";

const PostShow = () => (
    <RealTimeShow>
        <SimpleShowLayout>
            <TextField source="title" />
        </SimpleShowLayout>
    </RealTimeShow>
);

RealTimeShow

To trigger the <RealTimeShow> updates, the API has to publish events containing at least the following:

  • topic: '/resource/{resource}/{recordIdentifier}';
    
  •     data : {
            topic : '/resource/{resource}/{recordIdentifier}',
            type: '{deleted || updated}',
            payload: { id: [{recordIdentifier}]},
        }
    

The <RealTimeShow> allows you to customize the side effects triggered when it receives a new event, by passing a function to the onEventReceived prop:

import { SimpleShowLayout, TextField, useRefresh } from 'react-admin';
import { RealTimeShow } from '@react-admin/ra-realtime';

const PostShow = () => {
    const notify = useNotify();

    const handleEventReceived = (event, { setDeleted }) => {
        if (event.type === EventType.Updated) {
            notify('ra-realtime.notification.record.updated', 'info');
            refresh();
        } else if (event.type === EventType.Deleted) {
            notify('ra-realtime.notification.record.deleted', 'info');
            setDeleted(true);
        }
    };

    return (
        <RealTimeShow onEventReceived={handleEventReceived}>
            <SimpleShowLayout>
                <TextField source="title" />
            </SimpleShowLayout>
        </RealTimeShow>
    );
};
import { SimpleShowLayout, TextField } from "react-admin";
import { RealTimeShow } from "@react-admin/ra-realtime";

const PostShow = () => {
    const notify = useNotify();

    const handleEventReceived = (event, { setDeleted }) => {
        if (event.type === EventType.Updated) {
            notify("ra-realtime.notification.record.updated", "info");
            refresh();
        } else if (event.type === EventType.Deleted) {
            notify("ra-realtime.notification.record.deleted", "info");
            setDeleted(true);
        }
    };

    return (
        <RealTimeShow onEventReceived={handleEventReceived}>
            <SimpleShowLayout>
                <TextField source="title" />
            </SimpleShowLayout>
        </RealTimeShow>
    );
};

The function passed to onEventReceived will be called with the event as its first argument and an object containing functions that will update the UI:

  • setDeleted: If set to true, the edit view will show a message to let users know this record has been deleted.

<RealTimeMenu>

The <RealTimeMenu> component displays a badge with the number of updated records on each unactive Menu item.

import React from 'react';
import { Admin, Layout, LayoutProps, Resource } from 'react-admin';
import { RealTimeMenu } from '@react-admin/ra-realtime';
import { PostList, PostShow, PostEdit, realTimeDataProvider } from '.';

const CustomLayout = (props: LayoutProps) => (
    <Layout {...props} menu={RealTimeMenu} />
);

const MyReactAdmin = () => (
    <Admin dataProvider={realTimeDataProvider} layout={CustomLayout}>
        <Resource
            name="posts"
            list={PostList}
            show={PostShow}
            edit={PostEdit}
        />
    </Admin>
);
import React from "react";
import { Admin, Layout, Resource } from "react-admin";
import { RealTimeMenu } from "@react-admin/ra-realtime";
import { PostList, PostShow, PostEdit, realTimeDataProvider } from ".";

const CustomLayout = (props) => <Layout {...props} menu={RealTimeMenu} />;

const MyReactAdmin = () => (
    <Admin dataProvider={realTimeDataProvider} layout={CustomLayout}>
        <Resource name="posts" list={PostList} show={PostShow} edit={PostEdit} />
    </Admin>
);

RealTimeMenu

To trigger the <RealTimeMenu> behavior, the API has to publish events containing at least the followings keys:

  • topic: '/resource/{resource}';
    
  •   data : {
          topic : '/resource/{resource}',
          type: '{deleted || created || updated}',
          payload: { ids: [{listOfRecordIdentifiers}]},
      }
    

<RealTimeMenuItemLink> displays a badge with the number of updated records if the current menu item is not active (Used to build <RealTimeMenu> and your custom <MyRealTimeMenu>).

import React from 'react';
import { MenuProps } from 'react-admin';
import { RealTimeMenuItemLink } from '@react-admin/ra-realtime';

const CustomRealTimeMenu = () => {
    return (
        <div>
            <RealTimeMenuItemLink
                to="/posts"
                primaryText="The Posts"
                resource="posts"
                badgeColor="primary"
            />
            <RealTimeMenuItemLink
                to="/comments"
                primaryText="The Comments"
                resource="comments"
            />
        </div>
    );
};
import React from "react";
import { RealTimeMenuItemLink } from "@react-admin/ra-realtime";

const CustomRealTimeMenu = () => {
    return (
        <div>
            <RealTimeMenuItemLink to="/posts" primaryText="The Posts" resource="posts" badgeColor="primary" />
            <RealTimeMenuItemLink to="/comments" primaryText="The Comments" resource="comments" />
        </div>
    );
};

<RealTimeMenuItemLink> has two additional props compared to <MenuItemLink>:

  • resource: Needed, The name of the concerned resource (can be different from the path in the to prop)
  • badgeColor: Optional, It's the MUI color used to display the color of the badge. Default is alert (not far from the red). It can also be primary, secondary, or any of the MUI colors available in the MUI palette.

The badge displays the total number of changed records since the last time the <MenuItem> opened. The badge value resets whenever the user opens the resource list page, and the <MenuItem> becomes active.

To trigger <RealTimeMenuItemLink> behavior, the API has to publish events containing at least the following elements:

  • {
        topic: '/resource/{resource}';
    }
    
  • {
          data : {
              topic : '/resource/{resource}',
              type: '{deleted || created || updated}',
              payload: { ids: [{listOfRecordIdentifiers}]},
          }
    }
    

useLockRecord

Locks a single record, so that it cannot be edited or deleted by another user.

This hook calls dataProvider.lock() on mount and dataProvider.unlock() on unmount to lock and unlock the record.

import { useLockRecord } from '@react-admin/ra-realtime';

// use this component e.g. in an `<Edit>` component
const MyLockStatus = () => {
    const { isLocked } = useLockRecord();
    return isLocked ? <div>You are locking this record</div> : null;
};
import { useLockRecord } from "@react-admin/ra-realtime";

// use this component e.g. in an `<Edit>` component
const MyLockStatus = () => {
    const { isLocked } = useLockRecord();
    return isLocked ? <div>You are locking this record</div> : null;
};
// additional parameters
const { isLocked, isLoading, error } = useLockRecord({
    identity,
    resource,
    id,
    meta,
    lockMutationOptions,
    unlockMutationOptions,
});
// additional parameters
const { isLocked, isLoading, error } = useLockRecord({
    identity,
    resource,
    id,
    meta,
    lockMutationOptions,
    unlockMutationOptions,
});

It accepts an options parameter with the following properties (all optional):

  • identity: An identifier (string or number) corresponding to the identity of the locker (eg. '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 (eg. 'posts'). The hook uses the ResourceContext if not provided.
  • id: The record id (eg. 123). The hook uses the RecordContext if not provided.
  • meta: An object that will be forwarded to the dataProvider
  • 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

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 (eg. 'posts')
  • id: the record id (eg. 123)
  • meta: Optional. an object that will be forwarded to the dataProvider (optional)
  • lockMutationOptions: Optional. react-query mutation options, used to customize the lock side-effects for instance
  • unlockMutationOptions: Optional. react-query mutation options, used to customize the unlock side-effects for instance

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

const CustomToolbar = () => {
    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 (
        <Toolbar>
            <SaveButton disabled={isLockedByOtherUser} />
            {isLockedByOtherUser && (
                <LockMessage>
                    {`This record is locked by another user: ${lock?.dentity}.`}
                </LockMessage>
            )}
        </Toolbar>
    );
};
const CustomToolbar = () => {
    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 (
        <Toolbar>
            <SaveButton disabled={isLockedByOtherUser} />
            {isLockedByOtherUser && (
                <LockMessage>{`This record is locked by another user: ${lock?.dentity}.`}</LockMessage>
            )}
        </Toolbar>
    );
};

useGetLocks

Get all the locks for a given resource.

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

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

const MyPostGrid = () => {
    const resource = useResourceContext();
    const { data: locks } = useGetLocks(resource);
    return (
        <Datagrid
            bulkActionButtons={false}
            sx={{
                '& .MuiTableCell-root:last-child': {
                    textAlign: 'right',
                },
            }}
        >
            <MyPostTitle label="Title" locks={locks} />
            <MyPostActions label="Actions" locks={locks} />
        </Datagrid>
    );
};

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

    return (
        <WrapperField label={label}>
            <TextField source="title" />
            {lock && (
                <span style={{ color: 'red' }}>
                    {` (Locked by ${lock.identity})`}
                </span>
            )}
        </WrapperField>
    );
};

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

    return (
        <WrapperField label={label}>
            <DeleteButton disabled={!!locked} />
            <LockableEditButton disabled={!!locked} />
        </WrapperField>
    );
};
const MyPostGrid = () => {
    const resource = useResourceContext();
    const { data: locks } = useGetLocks(resource);
    return (
        <Datagrid
            bulkActionButtons={false}
            sx={{
                "& .MuiTableCell-root:last-child": {
                    textAlign: "right",
                },
            }}
        >
            <MyPostTitle label="Title" locks={locks} />
            <MyPostActions label="Actions" locks={locks} />
        </Datagrid>
    );
};

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

    return (
        <WrapperField label={label}>
            <TextField source="title" />
            {lock && <span style={{ color: "red" }}>{` (Locked by ${lock.identity})`}</span>}
        </WrapperField>
    );
};

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

    return (
        <WrapperField label={label}>
            <DeleteButton disabled={!!locked} />
            <LockableEditButton disabled={!!locked} />
        </WrapperField>
    );
};

useLock and useUnlock

  • useLock(resource, { id, identity, meta }, options)
  • useUnlock(resource, { id, identity, meta }, options)

These hooks accept the following parameters:

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

For most use cases, you won't need to call the useLock and useUnlock hooks directly, and should rather use the useLockRecord orchestration hook which is responsible for calling these hooks for you. You may need to call useGetLock and useGetLocks however, as there is no orchestration hook for them.

Customizing Translation Messages

This module uses specific translations for displaying buttons and other texts. 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 {
    TranslationMessages as BaseTranslationMessages,
    raRealTimeEnglishMessages,
    raRealTimeFrenchMessages,
    RaRealTimeTranslationMessages,
} from '@react-admin/ra-realtime';

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

const customEnglishMessages: TranslationMessages = mergeTranslations(
    englishMessages,
    raRealTimeEnglishMessages,
    {
        'ra-realtime': {
            notification: {
                record: {
                    updated: 'Wow, this entry has been modified by a ghost',
                    deleted: 'Hey, a ghost has stolen this entry',
                },
                list: {
                    refreshed:
                        'Be carefull, this list has been refreshed with %{smart_count} %{name} %{type} by some ghosts',
                },
            },
        },
    }
);

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

export const MyApp = () => (
    <Admin dataProvider={myDataprovider} i18nProvider={i18nCustomProvider}>
        ...
    </Admin>
);
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";
import { raRealTimeEnglishMessages, raRealTimeFrenchMessages } from "@react-admin/ra-realtime";

const customEnglishMessages = mergeTranslations(englishMessages, raRealTimeEnglishMessages, {
    "ra-realtime": {
        notification: {
            record: {
                updated: "Wow, this entry has been modified by a ghost",
                deleted: "Hey, a ghost has stolen this entry",
            },
            list: {
                refreshed:
                    "Be carefull, this list has been refreshed with %{smart_count} %{name} %{type} by some ghosts",
            },
        },
    },
});

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

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

CHANGELOG

v4.1.1

2022-11-10

  • (fix) Allow useLockRecord to be used with no parameters

v4.1.0

2022-08-25

  • (feat) Add ability to call lock() and unlock() with different params at runtime

v4.0.2

2022-06-23

  • (fix) useGetRecordId fails for falsy values (such as 0)

v4.0.1

2022-06-08

  • (fix) Update peer dependencies ranges (support React 18)

v4.0.0

2022-06-07

  • Upgrade to react-admin v4
  • Lock state management is now made with react-query instead of redux
  • Make providing of current resource and recordId to hooks optional, since they can be fetched automatically using the context (hooks signatures has been changed accordingly, see below)
  • Upgrade to MUI v5
  • Add a new hook useGetRecordId to help with getting the recordId from either context or route
  • Fix addRealTimeMethodsBasedOnMercure: don't create an eventSource when there are no topics (avoids error 400)
  • Fix addRealTimeMethodsBasedOnMercure: fix missing promise return with lodash debounce
  • addLocksMethodsBasedOnALockResource: getLock no longer rejects when there are no locks (instead resolves with undefined)
  • Change dataProvider lock methods signatures, and add ability to handle a meta object

Breaking Changes

Changes To RealTimeEdit, RealTimeList and RealTimeShow

You no longer need to pass down the resource and record props to these components, as they will be fetched from context automatically.

  import { RealTimeEdit } from '@react-admin/ra-realtime'

- const PostEdit = props => (
-     <RealTimeEdit {...props}>
+ const PostEdit = () => (
+     <RealTimeEdit>
          <SimpleForm>
              <TextInput source="title" />
          </SimpleForm>
      </RealTimeEdit>
  );

Also, the Warning component, used to display alerts if the record has been changed by someone else, now makes use of MUI's Alert component. This results in minor changes in appearance, and might also require you to change some classes name if you had custom styles for this component.

The sidebarIsOpen prop was removed, since this state is now determined using react-admin useSidebarState.

Also, you no longer need to forward the onClick prop to RealTimeMenuItemLink if you don't have a custom callback. Clicking on a menu item will now close the menu on small screens, just like the community version of react-admin.

- const MyMenu: FC<any> = ({ onMenuClick }) => {
-     const open = useSelector<ReduxState, boolean>(
-         state => state.admin.ui.sidebarOpen
-     );
+ const MyMenu = () => {
      return (
          <div>
              <RealTimeMenuItemLink
                  to="/posts"
                  primaryText="The Posts"
                  resource="posts"
                  badgeColor="primary"
-                 onClick={onMenuClick}
-                 sidebarIsOpen={open}
              />
          </div>
      );
  };

Changes To useSubscribeToRecord

You no longer need to pass down the current resource and recordId if they can be retrieved from context:

- useSubscribeToRecord('posts', props.id, () => {
+ useSubscribeToRecord(() => {
      refresh();
	  notify('Record updated server-side');
  });

If you need to pass them manually, you can still do so, but the order of the parameters has changed:

- useSubscribeToRecord('posts', 123, () => {
+ useSubscribeToRecord(() => {
      refresh();
	  notify('Record updated server-side');
- });
+ }, 'posts', 123);

Changes To useSubscribeToRecordList

You no longer need to pass down the current resource if it can be retrieved from context:

- useSubscribeToRecordList('posts', () => {
+ useSubscribeToRecordList(() => {
      refresh();
	  notify('list updated server-side');
  });

If you need to pass it manually, you can still do so, but the order of the parameters has changed:

- useSubscribeToRecordList('posts', () => {
+ useSubscribeToRecordList(() => {
      refresh();
	  notify('list updated server-side');
- });
+ }, 'posts');

useHasLock Has Been Renamed To useGetLock

useHasLock has been renamed to useGetLock, and you no longer need to provide it with the current resource and recordId if they can be retrieved from context:

- const { loading, data: lock } = useHasLock(resource, recordId);
+ const { loading, data: lock } = useGetLock();

If you need to pass them manually, you can still do so, using the new hook signature:

- const { loading, data: lock } = useHasLock(resource, recordId);
+ const { loading, data: lock } = useGetLock(resource, { id: recordId });

useHasLocks Has Been Renamed To useGetLocks

useHasLocks has been renamed to useGetLocks, and you no longer need to provide it with the current resource if it can be retrieved from context:

- const { data: locks } = useHasLocks(resource);
+ const { data: locks } = useGetLocks();

If you need to pass it manually, you can still do so:

- const { data: locks } = useHasLocks(resource);
+ const { data: locks } = useGetLocks(resource);

useLock Has Been Added To Query The dataProvider.lock() Function

Note: There was already a hook called useLock, which has now been renamed to useLockRecord, and is responsible for calling the new useLock (as well as useUnlock) for you. Please see below for more details about this hook.

useLock has been added in order to query the dataProvider.lock() function using react-query. You need to provide this hook with the resource, recordId, and identity of the locker. Example usage:

const [lock, _mutation] = useLock('posts', { id: 123, identity: 'mario' });
React.useEffect(() => {
    lock();
}, []);
const [lock, _mutation] = useLock("posts", { id: 123, identity: "mario" });
React.useEffect(() => {
    lock();
}, []);

In most use cases, you would not want to use this hook directly, but rather the new hook useLockRecord (see below).

useUnlock Has Been Added To Query The dataProvider.unlock() Function

useUnlock has been added in order to query the dataProvider.unlock() function using react-query. You need to provide this hook with the resource, recordId, and identity of the locker. Example usage:

const [unlock, _mutation] = useUnlock('posts', { id: 123, identity: 'mario' });
React.useEffect(() => {
    unlock();
}, []);
const [unlock, _mutation] = useUnlock("posts", { id: 123, identity: "mario" });
React.useEffect(() => {
    unlock();
}, []);

In most use cases, you would not want to use this hook directly, but rather the new hook useLockRecord (see below).

Old useLock Hook Has Been Renamed To useLockRecord

The previous useLock hook has been renamed to useLockRecord, and its signature has changed.

This hook is responsible for orchestrating the lock and unlock of a record (which was previously accomplished by useLock), calling the new useLock and useUnlock hooks internally.

Below is an example showing how to replace the old hook by the new one:

- const { loading } = useLock(resource, recordId, 'mario', {
-     onSuccess: () => {
-         notify('ra-realtime.notification.lock.lockedByMe');
-     },
-     onFailure: () => {
-         notify('ra-realtime.notification.lock.lockedBySomeoneElse');
-     },
-     onUnlockSuccess: () => {
-         notify('ra-realtime.notification.lock.unlocked');
-     },
- });
+ const { isLocked, isLoading: loading } = useLockRecord({
+     identity: 'mario',
+     resource: resource,
+     id: recordId,
+     lockMutationOptions: {
+         onSuccess: () => {
+             notify('ra-realtime.notification.lock.lockedByMe');
+         },
+         onError: () => {
+             notify('ra-realtime.notification.lock.lockedBySomeoneElse');
+         },
+     },
+     unlockMutationOptions: {
+         onSuccess: () => {
+             notify('ra-realtime.notification.lock.unlocked');
+         },
+     },
+ });

Just like with useGetLock, you do not need to provide this hook with the current resource and recordId if they can be retrieved from context.

Below is the same example as before, without providing the resource and recordId:

- const { loading } = useLock(resource, recordId, 'mario', {
-     onSuccess: () => {
-         notify('ra-realtime.notification.lock.lockedByMe');
-     },
-     onFailure: () => {
-         notify('ra-realtime.notification.lock.lockedBySomeoneElse');
-     },
-     onUnlockSuccess: () => {
-         notify('ra-realtime.notification.lock.unlocked');
-     },
- });
+ const { isLocked, isLoading: loading } = useLockRecord({
+     identity: 'mario',
+     lockMutationOptions: {
+         onSuccess: () => {
+             notify('ra-realtime.notification.lock.lockedByMe');
+         },
+         onError: () => {
+             notify('ra-realtime.notification.lock.lockedBySomeoneElse');
+         },
+     },
+     unlockMutationOptions: {
+         onSuccess: () => {
+             notify('ra-realtime.notification.lock.unlocked');
+         },
+     },
+ });

Changes To The dataProvider.getLock Method

The getLock method will now be called like so:

  await locksDataProvider.getLock('post', {
-     recordId: 143,
+     id: 143,
  });

Also, please note that the getLock method must now return a Promise<GetLockResult>.

Changes To The dataProvider.getLocks Method

The signature of the getLocks method has not changed.

However, please note that the getLocks method must now return a Promise<GetLocksResult>.

Changes To The dataProvider.lock Method

The lock method will now be called like so:

  await locksDataProvider.lock('post', {
-     recordId: 143,
+     id: 143,
      identity: 'adrien' // It could be an authentication token
  });

Also, please note that the lock method must now return a Promise<LockResult>.

Changes To The dataProvider.unlock Method

The unlock method will now be called like so:

  await locksDataProvider.unlock('post', {
-     recordId: 143,
+     id: 143,
      identity: 'adrien' // It could be an authentication token
  });

No changes were made to the return type.

v1.4.3

2021-09-16

  • (fix) Fix usage with latest Mercure version

v1.4.2

2021-07-20

  • (fix) Remove dependency to material-ui lab

v1.4.1

2021-06-29

  • (fix) Update peer dependencies ranges (support react 17)

v1.4.0

2021-05-25

  • (feat) Provide custom event handlers helper functions to update the views state.

Example for the Edit View

import React from 'react';
import { SimpleForm, TextInput, useRefresh } from 'react-admin';
import { RealTimeEdit } from '@react-admin/ra-realtime';

const PostEdit = props => {
    const notify = useNotify();

    const handleEventReceived = (
        event,
        { setDeleted, setUpdated, setUpdatedDisplayed }
    ) => {
        if (event.type === EventType.Updated) {
            notify('ra-realtime.notification.record.updated', 'info');
            setUpdated(true);
            setUpdatedDisplayed(true);
        } else if (event.type === EventType.Deleted) {
            notify('ra-realtime.notification.record.deleted', 'info');
            setDeleted(true);
            setUpdated(false);
            setUpdatedDisplayed(true);
        }
    };

    return (
        <RealTimeEdit {...props} onEventReceived={handleEventReceived}>
            <SimpleForm>
                <TextInput source="title" />
            </SimpleForm>
        </RealTimeEdit>
    );
};
import React from "react";
import { SimpleForm, TextInput } from "react-admin";
import { RealTimeEdit } from "@react-admin/ra-realtime";

const PostEdit = (props) => {
    const notify = useNotify();

    const handleEventReceived = (event, { setDeleted, setUpdated, setUpdatedDisplayed }) => {
        if (event.type === EventType.Updated) {
            notify("ra-realtime.notification.record.updated", "info");
            setUpdated(true);
            setUpdatedDisplayed(true);
        } else if (event.type === EventType.Deleted) {
            notify("ra-realtime.notification.record.deleted", "info");
            setDeleted(true);
            setUpdated(false);
            setUpdatedDisplayed(true);
        }
    };

    return (
        <RealTimeEdit {...props} onEventReceived={handleEventReceived}>
            <SimpleForm>
                <TextInput source="title" />
            </SimpleForm>
        </RealTimeEdit>
    );
};

Example for the Show View

import { SimpleShowLayout, TextField, useRefresh } from 'react-admin';
import { RealTimeShow } from '@react-admin/ra-realtime';

const PostShow = props => {
    const notify = useNotify();

    const handleEventReceived = (event, { setDeleted }) => {
        if (event.type === EventType.Updated) {
            notify('ra-realtime.notification.record.updated', 'info');
            refresh();
        } else if (event.type === EventType.Deleted) {
            notify('ra-realtime.notification.record.deleted', 'info');
            setDeleted(true);
        }
    };

    return (
        <RealTimeShow {...props} onEventReceived={handleEventReceived}>
            <SimpleShowLayout>
                <TextField source="title" />
            </SimpleShowLayout>
        </RealTimeShow>
    );
};
import { SimpleShowLayout, TextField } from "react-admin";
import { RealTimeShow } from "@react-admin/ra-realtime";

const PostShow = (props) => {
    const notify = useNotify();

    const handleEventReceived = (event, { setDeleted }) => {
        if (event.type === EventType.Updated) {
            notify("ra-realtime.notification.record.updated", "info");
            refresh();
        } else if (event.type === EventType.Deleted) {
            notify("ra-realtime.notification.record.deleted", "info");
            setDeleted(true);
        }
    };

    return (
        <RealTimeShow {...props} onEventReceived={handleEventReceived}>
            <SimpleShowLayout>
                <TextField source="title" />
            </SimpleShowLayout>
        </RealTimeShow>
    );
};

v1.3.2

2021-04-26

  • (performances) Replace MUI boxes by div with styles.

v1.3.1

2021-03-18

  • (fix) Disable List Notifications

v1.3.0

2021-02-18

  • (feat) Allows to customize side effects when an event is received in RealTimeList, RealTimeEdit and RealTimeShow via the onEventReceived prop.
import { useRefresh } from 'react-admin';
import { RealTimeList } from '@react-admin/ra-realtime';

const PostList = props => {
    const refresh = useRefresh();
    const handleEventReceived = event => {
        refresh();
    };

    return (
        <RealTimeList {...props} onEventReceived={handleEventReceived}>
            <Datagrid>
                <TextField source="title" />
            </Datagrid>
        </RealTimeList>
    );
};

v1.2.1

2020-11-18

  • Upgrade to react-admin 3.10

v1.2.0

2020-10-05

  • Upgrade to react-admin 3.9

v1.1.3

2020-09-30

  • Update Readme

v1.1.2

2020-09-30

  • (fix) Fix bad export in realtime Edit Storybook Action

v1.1.1

2020-09-28

  • (fix) Fix extra spacing in storybook edit examples

v1.1.0

2020-09-21

  • (feat) Add the autoclaim capability (autolock when unlocked) to the useLock hook
  • (fix) Fix missing storybook examples for realtime locking

v1.0.5

2020-09-18

  • (fix) Fix non-working Mercure storybook examples

v1.0.4

2020-09-15

  • (fix) Fix missing export
  • (deps) Upgrade dependencies

v1.0.3

2020-09-14

  • (feat) Add a useHasLocks hook to select all locks for a resource

v1.0.2

2020-09-10

  • (fix) Add missing resource security check on the useHasLock selector

v1.0.0

2020/08/04

  • First release