react-admin ≥ 4.5.1

ra-realtime

A full-featured real-time module for React-Admin.

ra-realtime provides hooks and UI components for collaborative applications where several people work in parallel. It allows publishing and subscribing to real-time events, updating views when another user pushes a change, notifying end users of events, and preventing data loss when two editors work on the same resource concurrently.

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

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 install this package.

You will need a data provider that supports real-time subscriptions. Check out the Data Provider Requirements section for more information.

Features

Publish/Subscribe

At its core, ra-realtime 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).

ra-realtime 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, replace <List> with <ListLive> 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 { ListLive } from '@react-admin/ra-realtime';

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

useSubscribeToRecordList

This feature leverages the following hooks:

And the following components:

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.

MenuLive

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

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

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

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 { MenuLive } from "@react-admin/ra-realtime";
import { PostList, PostShow, PostEdit, realTimeDataProvider } from ".";

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

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

This feature leverages the following components:

Locks

And last but not least, ra-realtime provides a lock mechanism to prevent two users from editing the same resource at the same time.

Edit With Locks

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.

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

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 {
  ListLive,
  addRealTimeMethodsBasedOnApiPlatform,
} from "@react-admin/ra-realtime";

const dataProvider = hydraDataProvider("https://localhost:8443");
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: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 a 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 = () => (
  <ListLive>
    <Datagrid>
      <FieldGuesser source="name" />
      <EditButton />
    </Datagrid>
  </ListLive>
);
import { Datagrid, EditButton } from "react-admin";
import {
  HydraAdmin,
  ResourceGuesser,
  FieldGuesser,
  hydraDataProvider,
} from "@api-platform/admin";
import {
  ListLive,
  addRealTimeMethodsBasedOnApiPlatform,
} from "@react-admin/ra-realtime";

const dataProvider = hydraDataProvider("https://localhost:8443");
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: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 a 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 = () => (
  <ListLive>
    <Datagrid>
      <FieldGuesser source="name" />
      <EditButton />
    </Datagrid>
  </ListLive>
);

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("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 { 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>
);

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

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

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.

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:

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

useSubscribe

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 { Badge, Typography } from "@mui/material";
import { useSubscribe } from "@react-admin/ra-realtime";

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 ? (
    <Badge badgeContent={nbMessages} color="primary">
      <Typography># {name}</Typography>
    </Badge>
  ) : (
    <Typography># {name}</Typography>
  );
};
import { useState, useCallback } from "react";
import { Badge, Typography } from "@mui/material";
import { useSubscribe } from "@react-admin/ra-realtime";

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 ? (
    <Badge badgeContent={nbMessages} color="primary">
      <Typography># {name}</Typography>
    </Badge>
  ) : (
    <Typography># {name}</Typography>
  );
};

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 { LinearProgress } from "@mui/material";
import { useSubscribe } from "@react-admin/ra-realtime";

const JobProgress = ({ jobId }) => {
  const [progress, setProgress] = useState(0);
  const [color, setColor] = useState("primary");
  const callback = useCallback(
    (event, unsubscribe) => {
      if (event.type === "progress") {
        setProgress(event.payload.progress);
      }
      if (event.type === "completed") {
        setColor("success");
        unsubscribe();
      }
    },
    [setColor]
  );
  useSubscribe(`jobs/${jobId}`, callback);
  return (
    <LinearProgress variant="determinate" value={progress} color={color} />
  );
};
import { useState, useCallback } from "react";
import { LinearProgress } from "@mui/material";
import { useSubscribe } from "@react-admin/ra-realtime";

const JobProgress = ({ jobId }) => {
  const [progress, setProgress] = useState(0);
  const [color, setColor] = useState("primary");
  const callback = useCallback(
    (event, unsubscribe) => {
      if (event.type === "progress") {
        setProgress(event.payload.progress);
      }
      if (event.type === "completed") {
        setColor("success");
        unsubscribe();
      }
    },
    [setColor]
  );
  useSubscribe(`jobs/${jobId}`, callback);
  return (
    <LinearProgress variant="determinate" value={progress} color={color} />
  );
};

Using 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 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-realtime";

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

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

useSubscribeOnce

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.

useSubscribeCallback

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 "react-admin";
import { Button, Card, Alert, AlertTitle, LinearProgress } from "@mui/material";
import { useSubscribeCallback } from "@react-admin/ra-realtime";

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();
        }}
      >
        Launch recompute
      </Button>
      {progress > 0 && (
        <Card sx={{ m: 2, maxWidth: 400 }}>
          <Alert severity={progress === 100 ? "success" : "info"}>
            <AlertTitle>
              Recompute {progress === 100 ? "complete" : "in progress"}
            </AlertTitle>
            <LinearProgressWithLabel value={progress} />
          </Alert>
        </Card>
      )}
    </div>
  );
};
import { useState, useCallback } from "react";
import { useDataProvider } from "react-admin";
import { Button, Card, Alert, AlertTitle } from "@mui/material";
import { useSubscribeCallback } from "@react-admin/ra-realtime";

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();
        }}
      >
        Launch recompute
      </Button>
      {progress > 0 && (
        <Card sx={{ m: 2, maxWidth: 400 }}>
          <Alert severity={progress === 100 ? "success" : "info"}>
            <AlertTitle>
              Recompute {progress === 100 ? "complete" : "in progress"}
            </AlertTitle>
            <LinearProgressWithLabel value={progress} />
          </Alert>
        </Card>
      )}
    </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 'react-admin';
import { useSubscribeCallback } from '@react-admin/ra-realtime';

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

useSubscribeOnceCallback

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.

useSubscribeToRecord

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 dialog 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 (
    <Dialog
      open={open}
      onClose={handleClose}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogTitle id="alert-dialog-title">
        Post Updated by {author}
      </DialogTitle>
      <DialogContent>
        <DialogContentText id="alert-dialog-description">
          Your changes and their changes may conflict. What do you want to do?
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button onClick={handleClose}>Keep my changes</Button>
        <Button onClick={refresh}>Get their changes (and lose mine)</Button>
      </DialogActions>
    </Dialog>
  );
};

const PostEdit = () => (
  <Edit>
    <SimpleForm>
      <TextInput source="id" disabled />
      <TextInput source="title" fullWidth />
      <TextInput source="body" fullWidth multiline />
      <WarnWhenUpdatedBySomeoneElse />
    </SimpleForm>
  </Edit>
);
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 (
    <Dialog
      open={open}
      onClose={handleClose}
      aria-labelledby="alert-dialog-title"
      aria-describedby="alert-dialog-description"
    >
      <DialogTitle id="alert-dialog-title">
        Post Updated by {author}
      </DialogTitle>
      <DialogContent>
        <DialogContentText id="alert-dialog-description">
          Your changes and their changes may conflict. What do you want to do?
        </DialogContentText>
      </DialogContent>
      <DialogActions>
        <Button onClick={handleClose}>Keep my changes</Button>
        <Button onClick={refresh}>Get their changes (and lose mine)</Button>
      </DialogActions>
    </Dialog>
  );
};

const PostEdit = () => (
  <Edit>
    <SimpleForm>
      <TextInput source="id" disabled />
      <TextInput source="title" fullWidth />
      <TextInput source="body" fullWidth multiline />
      <WarnWhenUpdatedBySomeoneElse />
    </SimpleForm>
  </Edit>
);

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.

useSubscribeToRecordList

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

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 = () => (
  <List>
    <Datagrid>
      <TextField source="id" />
      <TextField source="title" />
      <TextField source="director" />
      <TextField source="year" />
    </Datagrid>
    <ListWatcher />
  </List>
);
import React from "react";
import { useNotify, useListContext } from "react-admin";
import { useSubscribeToRecordList } from "@react-admin/ra-realtime";

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 = () => (
  <List>
    <Datagrid>
      <TextField source="id" />
      <TextField source="title" />
      <TextField source="director" />
      <TextField source="year" />
    </Datagrid>
    <ListWatcher />
  </List>
);

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

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

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

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

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 { List, useRecordContext } from "react-admin";
import LockIcon from "@mui/icons-material/Lock";
import { useGetLocksLive } from "@react-admin/ra-realtime";

const LockField = ({ locks }) => {
  const record = useRecordContext();
  if (!record) return null;
  const lock = locks?.find((lock) => lock.recordId === record?.id);
  if (!lock) return <Box width={20} />;
  return <LockIcon fontSize="small" color="disabled" />;
};

const PostList = () => {
  const { data: locks } = useGetLocksLive();
  return (
    <List>
      <Datagrid>
        <TextField source="title" />
        <LockField locks={locks} />
      </Datagrid>
    </List>
  );
};
import { List, useRecordContext } from "react-admin";
import LockIcon from "@mui/icons-material/Lock";
import { useGetLocksLive } from "@react-admin/ra-realtime";

const LockField = ({ locks }) => {
  const record = useRecordContext();
  if (!record) return null;
  const lock = locks?.find((lock) => lock.recordId === record?.id);
  if (!lock) return <Box width={20} />;
  return <LockIcon fontSize="small" color="disabled" />;
};

const PostList = () => {
  const { data: locks } = useGetLocksLive();
  return (
    <List>
      <Datagrid>
        <TextField source="title" />
        <LockField locks={locks} />
      </Datagrid>
    </List>
  );
};

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

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.

Locking a record

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 { Edit, SimpleForm, TextInput } from "react-admin";
import { useLockOnMount } from "@react-admin/ra-realtime";
import { Alert, AlertTitle, Box } from "@material-ui/core";

const PostAside = () => {
  const { isLocked, error, isLoading } = useLockOnMount();
  return (
    <Box width={200} ml={1}>
      {isLoading && <Alert severity="info">Locking post...</Alert>}
      {error && (
        <Alert severity="warning">
          <AlertTitle>Failed to lock</AlertTitle>Someone else is probably
          already locking it.
        </Alert>
      )}
      {isLocked && (
        <Alert severity="success">
          <AlertTitle>Post locked</AlertTitle> Only you can edit it.
        </Alert>
      )}
    </Box>
  );
};

const PostEdit = () => (
  <Edit aside={<PostAside />}>
    <SimpleForm>
      <TextInput source="title" fullWidth />
      <TextInput source="headline" fullWidth multiline />
      <TextInput source="author" fullWidth />
    </SimpleForm>
  </Edit>
);
import { Edit, SimpleForm, TextInput } from "react-admin";
import { useLockOnMount } from "@react-admin/ra-realtime";
import { Alert, AlertTitle, Box } from "@material-ui/core";

const PostAside = () => {
  const { isLocked, error, isLoading } = useLockOnMount();
  return (
    <Box width={200} ml={1}>
      {isLoading && <Alert severity="info">Locking post...</Alert>}
      {error && (
        <Alert severity="warning">
          <AlertTitle>Failed to lock</AlertTitle>Someone else is probably
          already locking it.
        </Alert>
      )}
      {isLocked && (
        <Alert severity="success">
          <AlertTitle>Post locked</AlertTitle> Only you can edit it.
        </Alert>
      )}
    </Box>
  );
};

const PostEdit = () => (
  <Edit aside={<PostAside />}>
    <SimpleForm>
      <TextInput source="title" fullWidth />
      <TextInput source="headline" fullWidth multiline />
      <TextInput source="author" fullWidth />
    </SimpleForm>
  </Edit>
);

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

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().

Locking a record

Usage

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

import { Edit, SimpleForm, TextInput } from "react-admin";
import { useLockOnMount } from "@react-admin/ra-realtime";
import { Alert, AlertTitle, Box, Button } from "@material-ui/core";

const PostAside = () => {
  const [doLock, { data, error, isLoading }] = useLockOnCall();
  return (
    <Box width={200} ml={1}>
      {isLoading ? (
        <Alert severity="info">Locking post...</Alert>
      ) : error ? (
        <Alert severity="warning">
          <AlertTitle>Failed to lock</AlertTitle>Someone else is probably
          already locking it.
        </Alert>
      ) : data ? (
        <Alert severity="success">
          <AlertTitle>Post locked</AlertTitle> Only you can edit it.
        </Alert>
      ) : (
        <Button onClick={() => doLock()} fullWidth>
          Lock post
        </Button>
      )}
    </Box>
  );
};
const PostEdit = () => (
  <Edit aside={<PostAside />}>
    <SimpleForm>
      <TextInput source="title" fullWidth />
      <TextInput source="headline" fullWidth multiline />
      <TextInput source="author" fullWidth />
    </SimpleForm>
  </Edit>
);
import { Edit, SimpleForm, TextInput } from "react-admin";
import { Alert, AlertTitle, Box, Button } from "@material-ui/core";

const PostAside = () => {
  const [doLock, { data, error, isLoading }] = useLockOnCall();
  return (
    <Box width={200} ml={1}>
      {isLoading ? (
        <Alert severity="info">Locking post...</Alert>
      ) : error ? (
        <Alert severity="warning">
          <AlertTitle>Failed to lock</AlertTitle>Someone else is probably
          already locking it.
        </Alert>
      ) : data ? (
        <Alert severity="success">
          <AlertTitle>Post locked</AlertTitle> Only you can edit it.
        </Alert>
      ) : (
        <Button onClick={() => doLock()} fullWidth>
          Lock post
        </Button>
      )}
    </Box>
  );
};
const PostEdit = () => (
  <Edit aside={<PostAside />}>
    <SimpleForm>
      <TextInput source="title" fullWidth />
      <TextInput source="headline" fullWidth multiline />
      <TextInput source="author" fullWidth />
    </SimpleForm>
  </Edit>
);

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

const LatestNews = () => {
  const { data, total, isLoading, error } = useGetListLive("posts", {
    pagination: { page: 1, perPage: 10 },
    sort: { field: "published_at", order: "DESC" },
  });
  if (isLoading) {
    return <Loading />;
  }
  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-realtime";

const LatestNews = () => {
  const { data, total, isLoading, error } = useGetListLive("posts", {
    pagination: { page: 1, perPage: 10 },
    sort: { field: "published_at", order: "DESC" },
  });
  if (isLoading) {
    return <Loading />;
  }
  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 "react-admin";
import { useGetOneLive } from "@react-admin/ra-realtime";

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

const UserProfile = () => {
  const record = useRecordContext();
  const { data, isLoading, error } = useGetOneLive("users", {
    id: record.id,
  });
  if (isLoading) {
    return <Loading />;
  }
  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.

<ListLive>

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

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

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

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

ListLive

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

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

The <ListLive> 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 { ListLive } 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(`${count} items updated by another user`);
    refresh();
  };

  return (
    <ListLive onEventReceived={handleEventReceived}>
      <Datagrid>
        <TextField source="title" />
      </Datagrid>
    </ListLive>
  );
};
import { Datagrid, TextField, useNotify, useRefresh } from "react-admin";
import { ListLive } 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(`${count} items updated by another user`);
    refresh();
  };

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

<EditLive>

<EditLive> 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 { EditLive } from "@react-admin/ra-realtime";

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

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

EditLive

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

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

The <EditLive> 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 { EditLive, EventType } from "@react-admin/ra-realtime";

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

  const handleEventReceived = (
    event,
    { setDeleted, setUpdated, setUpdatedDisplayed }
  ) => {
    if (event.type === EventType.Updated) {
      notify("Record updated");
      setUpdated(true);
      setUpdatedDisplayed(true);
    } else if (event.type === EventType.Deleted) {
      notify("Record deleted");
      setDeleted(true);
      setUpdated(false);
      setUpdatedDisplayed(true);
    }
  };

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

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

  const handleEventReceived = (
    event,
    { setDeleted, setUpdated, setUpdatedDisplayed }
  ) => {
    if (event.type === EventType.Updated) {
      notify("Record updated");
      setUpdated(true);
      setUpdatedDisplayed(true);
    } else if (event.type === EventType.Deleted) {
      notify("Record deleted");
      setDeleted(true);
      setUpdated(false);
      setUpdatedDisplayed(true);
    }
  };

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

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.

<ShowLive>

<ShowLive> 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 { ShowLive } from "@react-admin/ra-realtime";

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

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

ShowLive

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

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

The <ShowLive> 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 { ShowLive, EventType } from "@react-admin/ra-realtime";

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

  const handleEventReceived = (event, { setDeleted }) => {
    if (event.type === EventType.Updated) {
      notify("Record updated");
      refresh();
    } else if (event.type === EventType.Deleted) {
      notify("Record deleted");
      setDeleted(true);
    }
  };

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

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

  const handleEventReceived = (event, { setDeleted }) => {
    if (event.type === EventType.Updated) {
      notify("Record updated");
      refresh();
    } else if (event.type === EventType.Deleted) {
      notify("Record deleted");
      setDeleted(true);
    }
  };

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

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.

The <MenuLive> 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 { MenuLive } from "@react-admin/ra-realtime";
import { PostList, PostShow, PostEdit, realTimeDataProvider } from ".";

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

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 { MenuLive } from "@react-admin/ra-realtime";
import { PostList, PostShow, PostEdit, realTimeDataProvider } from ".";

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

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

MenuLive

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

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

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

import React from "react";
import { MenuProps } from "react-admin";
import { MenuLiveItemLink } from "@react-admin/ra-realtime";

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

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

<MenuLiveItemLink> 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. The 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 <MenuLiveItemLink> behavior, the API has to publish events containing at least the following elements:

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

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 {
  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",
        },
      },
    },
  }
);

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

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.3.1

2023-01-06

  • Rename <RealTimeList> to <ListLive>
  • Rename <RealTimeEdit> to <EditLive>
  • Rename <RealTimeShow> to <ShowLive>
  • Rename <RealTimeMenu> to <MenuLive>
  • Rename <RealTimeMenuItemLink> to <MenuLiveItemLink>

The old component names are still available, but deprecated. There is a slight breaking change: if you overrode the <RealTimeMenuItemLink> styles in your theme, you will need to change the componnent name from RaRealTimeMenuItemLink to RaMenuLiveItemLink.

v4.3.0

2023-01-05

  • Add usePublish hook
  • Add once, enabled, and unsubscribeOnUnmount options to useSubscribe hook
  • Add useSubscribeCallback hook
  • Rename useLockRecord to useLockOnMount
  • Add useLockOnCall hook
  • Add useGetListLive hook
  • Add useGetOneLive hook
  • Add useGetLockLive hook
  • Add useGetLocksLive hook
  • Add useSubscribeToLock hook
  • Add useSubscribeToLocks hook
  • Add addLocalCRUDEvents data provider builder
  • Add addRealTimeMethodsInLocalStorage data provider builder
  • Add typed exceptions for data provider failure cases
  • Add <WithLocks> component
  • Add LocksContext
  • Add stories for most hooks
  • Improve and expand documentation
  • Remove unused translations
  • Fix useGetLock and useGetLocks return types
  • Fix useLockRecord locks twice in strict mode

v4.2.0

2022-11-17

  • (feat) Upgrade react-admin to version 4.5.1
  • (feat) Use useGetRecordId from react-admin

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