ra-core-ee
Headless hooks and components for building enterprise-grade applications with React Admin.
Installation
npm install --save @react-admin/ra-core-ee
# or
yarn add @react-admin/ra-core-ee
Tip: ra-core-ee
is part of the React-Admin Enterprise Edition, and hosted in a private npm registry. You need to subscribe to one of the Enterprise Edition plans to access this package.
Authorization
getPermissionsFromRoles
This function returns an array of user permissions based on a role definition, a list of roles, and a list of user permissions. It merges the permissions defined in roleDefinitions
for the current user's roles (userRoles
) with the extra userPermissions
.
// static role definitions (usually in the app code)
const roleDefinitions = {
admin: [
{ action: '*', resource: '*' }
],
reader: [
{ action: ['list', 'show', 'export'], resource: '*' }
{ action: 'read', resource: 'posts.*' }
{ action: 'read', resource: 'comments.*' }
],
accounting: [
{ action: '*', resource: 'sales' },
],
};
const permissions = getPermissionsFromRoles({
roleDefinitions,
// roles of the current user (usually returned by the server upon login)
userRoles: ['reader'],
// extra permissions for the current user (usually returned by the server upon login)
userPermissions: [
{ action: 'list', resource: 'sales'},
],
});
// permissions = [
// { action: ['list', 'show', 'export'], resource: '*' },
// { action: 'read', resource: 'posts.*' },
// { action: 'read', resource: 'comments.*' },
// { action: 'list', resource: 'sales' },
// ];
This function takes an object as argument with the following fields:
roleDefinitions
: a dictionary containing the role definition for each roleuserRoles
(optional): an array of roles (admin, reader...) for the current useruserPermissions
(optional): an array of permissions for the current user
canAccessWithPermissions
canAccessWithPermissions
is a helper that facilitates the authProvider.canAccess()
method implementation:
import { canAccessWithPermissions } from '@react-admin/ra-core-ee';
const authProvider = {
// ...
canAccess: async ({ action, resource, record }) => {
const permissions = JSON.parse(localStorage.getItem('permissions'));
return canAccessWithPermissions({
permissions,
action,
resource,
record,
});
}
};
import { canAccessWithPermissions } from "@react-admin/ra-core-ee";
const authProvider = {
// ...
canAccess: async ({ action, resource, record }) => {
const permissions = JSON.parse(localStorage.getItem("permissions"));
return canAccessWithPermissions({
permissions,
action,
resource,
record,
});
},
};
canAccessWithPermissions
expects the permissions
to be a flat array of permissions. It is your responsibility to fetch these permissions (usually during login). If the permissions are spread into several role definitions, you can merge them into a single array using the getPermissionsFromRoles
function.
Realtime
Features
Publish/Subscribe
At its core, @react-admin/ra-core-ee
provides a pub/sub mechanism to send and receive real-time events. Events are sent to a topic, and all subscribers to this topic receive the event.
// on the publisher side
const [publish] = usePublish();
publish(topic, event);
// on the subscriber side
useSubscribe(topic, callback);
// on the publisher side
const [publish] = usePublish();
publish(topic, event);
// on the subscriber side
useSubscribe(topic, callback);
This package supports various realtime infrastructures (Mercure, API Platform, supabase, Socket.IO, Ably, and many more) thanks to the same adapter approach as for CRUD methods. In fact, the dataProvider
is used to send and receive events (see the Data Provider Requirements section for more information).
@react-admin/ra-core-ee
provides a set of high-level hooks to make it easy to work with real-time events:
Live Updates
Ra-realtime provides live updates via specialized hooks and components. This means that when a user edits a resource, the other users working on the same resource see the changes in real-time whether they are in a list, a show view, or an edit view.
For instance, include a <ListLiveUpdate>
within a <ListBase>
to have a list refreshing automatically when an element is added, updated, or deleted:
import { ListBase } from 'ra-core';
+import { ListLiveUpdate } from '@react-admin/ra-realtime';
const PostList = () => (
<ListBase>
...other children
+ <ListLiveUpdate />
</ListBase>
);
This feature leverages the following hooks:
-
useGetOneLive
` And the following components:
Locks
And last but not least, @react-admin/ra-core-ee
provides a lock mechanism to prevent two users from editing the same resource at the same time.
A user can lock a resource, either by voluntarily asking for a lock or by editing a resource. When a resource is locked, other users can't edit it. When the lock is released, other users can edit the resource again.
import { Form, useCreate, useGetIdentity, useRecordContext } from 'ra-core';
import { useGetLockLive } from '@react-admin/ra-core-ee';
import { TextInput, SelectInput } from '@components/admin/TextInput';
export const NewMessageForm = () => {
const [create, { isLoading: isCreating }] = useCreate();
const record = useRecordContext();
const { data: lock } = useGetLockLive('tickets', { id: record.id });
const { identity } = useGetIdentity();
const isFormDisabled = lock && lock.identity !== identity?.id;
const [doLock] = useLockOnCall({ resource: 'tickets' });
const handleSubmit = (values: any) => {
/* ... */
};
return (
<Form onSubmit={handleSubmit}>
<TextInput
source="message"
multiline
onFocus={() => {
doLock();
}}
disabled={isFormDisabled}
/>
<SelectInput
source="status"
choices={statusChoices}
disabled={isFormDisabled}
/>
<button type="submit" disabled={isCreating || isFormDisabled}>
Submit
</button>
</Form>
);
};
import { Form, useCreate, useGetIdentity, useRecordContext } from "ra-core";
import { useGetLockLive } from "@react-admin/ra-core-ee";
import { TextInput, SelectInput } from "@components/admin/TextInput";
export const NewMessageForm = () => {
const [create, { isLoading: isCreating }] = useCreate();
const record = useRecordContext();
const { data: lock } = useGetLockLive("tickets", { id: record.id });
const { identity } = useGetIdentity();
const isFormDisabled = lock && lock.identity !== identity?.id;
const [doLock] = useLockOnCall({ resource: "tickets" });
const handleSubmit = (values) => {
/* ... */
};
return (
<Form onSubmit={handleSubmit}>
<TextInput
source="message"
multiline
onFocus={() => {
doLock();
}}
disabled={isFormDisabled}
/>
<SelectInput source="status" choices={statusChoices} disabled={isFormDisabled} />
<button type="submit" disabled={isCreating || isFormDisabled}>
Submit
</button>
</Form>
);
};
This feature leverages the following hooks:
useLock
useUnlock
useGetLock
useGetLockLive
useGetLocks
useGetLocksLive
useLockOnCall
useLockOnMount
DataProvider Requirements
To enable real-time features, the dataProvider
must implement three new methods:
subscribe(topic, callback)
unsubscribe(topic, callback)
publish(topic, event)
(optional - publication is often done server-side)
These methods should return an empty Promise resolved when the action was acknowledged by the real-time bus.
In addition, to support the lock features, the dataProvider
must implement 4 more methods:
lock(resource, { id, identity, meta })
unlock(resource, { id, identity, meta })
getLock(resource, { id, meta })
getLocks(resource, { meta })
Supabase Adapter
The ra-realtime
package contains a function augmenting a regular (API-based) dataProvider
with real-time methods based on the capabilities of Supabase.
This adapter subscribes to Postgres Changes, and transforms the events into the format expected by ra-realtime
.
import { addRealTimeMethodsBasedOnSupabase, ListLiveUpdate } from '@react-admin/ra-core-ee';
import { supabaseDataProvider } from 'ra-supabase';
import { createClient } from '@supabase/supabase-js';
import { CoreAdmin, Resource, ListBase } from 'ra-core';
const supabaseClient = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY
);
const dataProvider = supabaseDataProvider({
instanceUrl: process.env.SUPABASE_URL,
apiKey: process.env.SUPABASE_ANON_KEY,
supabaseClient
});
const realTimeDataProvider = addRealTimeMethodsBasedOnSupabase({
dataProvider,
supabaseClient,
});
export const App = () => {
return (
<CoreAdmin dataProvider={realTimeDataProvider}>
<Resource name="sales" list={SaleList} />
</CoreAdmin>
);
};
const SaleList = () => (
<List>
{/* List view */}
<ListLiveUpdate />
</List>
);
import { addRealTimeMethodsBasedOnSupabase, ListLiveUpdate } from "@react-admin/ra-core-ee";
import { supabaseDataProvider } from "ra-supabase";
import { createClient } from "@supabase/supabase-js";
import { CoreAdmin, Resource } from "ra-core";
const supabaseClient = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);
const dataProvider = supabaseDataProvider({
instanceUrl: process.env.SUPABASE_URL,
apiKey: process.env.SUPABASE_ANON_KEY,
supabaseClient,
});
const realTimeDataProvider = addRealTimeMethodsBasedOnSupabase({
dataProvider,
supabaseClient,
});
export const App = () => {
return (
<CoreAdmin dataProvider={realTimeDataProvider}>
<Resource name="sales" list={SaleList} />
</CoreAdmin>
);
};
const SaleList = () => (
<List>
{/* List view */}
<ListLiveUpdate />
</List>
);
Tip: Realtime features are not enabled in Supabase by default, you need to enable them. This can be done either from the Replication section of your Supabase Dashboard, or by running the following SQL query with the SQL Editor:
begin;
-- remove the supabase_realtime publication
drop
publication if exists supabase_realtime;
-- re-create the supabase_realtime publication with no tables
create publication supabase_realtime;
commit;
-- add a table to the publication
alter
publication supabase_realtime add table sales;
alter
publication supabase_realtime add table contacts;
alter
publication supabase_realtime add table contactNotes;
Have a look at the Supabase Replication Setup documentation section for more info.
addRealTimeMethodsBasedOnSupabase
accepts the following parameters:
Prop | Required | Type | Default | Description |
---|---|---|---|---|
dataProvider |
Required | DataProvider |
- | The base dataProvider to augment with realtime methods |
supabaseClient |
Required | SupabaseClient |
- | The Supabase JS Client |
Custom Tokens
You may choose to sign your own tokens to customize claims that can be checked in your RLS policies. In order to use these custom tokens with addRealTimeMethodsBasedOnSupabase
, you must pass apikey
in both Realtime's headers
and params
when creating the supabaseClient
.
Please follow the instructions from the Supabase documentation for more information about how to do so.
API-Platform Adapter
The ra-realtime
package contains a function augmenting a regular (API-based) dataProvider
with real-time methods based on the capabilities of API-Platform. Use it as follows:
import { ListBase } from 'ra-core';
import {
HydraAdmin,
ResourceGuesser,
FieldGuesser,
hydraDataProvider,
} from '@api-platform/admin';
import {
ListLiveUpdate,
addRealTimeMethodsBasedOnApiPlatform,
} from '@react-admin/ra-core-ee';
const dataProvider = hydraDataProvider({
entrypoint: 'https://localhost',
});
const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
// The original dataProvider (should be a hydra data provider passed by API-Platform)
dataProvider,
// The API-Platform Mercure Hub URL
'https://localhost/.well-known/mercure',
// JWT token to authenticate against the API-Platform Mercure Hub
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM',
// The topic URL used by API-Platform (without a slash at the end)
'https://localhost'
);
const App = () => {
return (
<HydraAdmin
entrypoint="https://localhost"
dataProvider={realTimeDataProvider}
>
<ResourceGuesser name="greetings" list={GreetingsList} />
</HydraAdmin>
);
};
// Example for connecting a list of greetings
const GreetingsList = () => (
<ListBase>
{/* List view */}
</ListBase>
);
import { ListBase } from "ra-core";
import { HydraAdmin, ResourceGuesser, hydraDataProvider } from "@api-platform/admin";
import { addRealTimeMethodsBasedOnApiPlatform } from "@react-admin/ra-core-ee";
const dataProvider = hydraDataProvider({
entrypoint: "https://localhost",
});
const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
// The original dataProvider (should be a hydra data provider passed by API-Platform)
dataProvider,
// The API-Platform Mercure Hub URL
"https://localhost/.well-known/mercure",
// JWT token to authenticate against the API-Platform Mercure Hub
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM",
// The topic URL used by API-Platform (without a slash at the end)
"https://localhost"
);
const App = () => {
return (
<HydraAdmin entrypoint="https://localhost" dataProvider={realTimeDataProvider}>
<ResourceGuesser name="greetings" list={GreetingsList} />
</HydraAdmin>
);
};
// Example for connecting a list of greetings
const GreetingsList = () => <ListBase>{/* List view */}</ListBase>;
The addRealTimeMethodsBasedOnApiPlatform
function also accepts an optional 5th argument allowing to customize the transformTopicFromRaRealtime
function (responsible for transforming the topic
argument from the Admin
into a valid Mercure topic for Api Platform).
import { hydraDataProvider } from '@api-platform/admin';
import { addRealTimeMethodsBasedOnApiPlatform } from '@react-admin/ra-realtime';
const dataProvider = hydraDataProvider({
entrypoint: 'https://localhost',
});
function myTransformTopicFromRaRealtime(topic: string): string {
const [_basename, _resourcePrefix, resource, ...id] = topic.split('/');
if (!id || id.length === 0) {
return `/${resource}/{id}`;
}
const originId = id[2];
return `/${resource}/${originId}`;
}
const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
dataProvider,
'https://localhost/.well-known/mercure',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM',
'https://localhost',
// Pass the custom transformTopicFromRaRealtime function here
myTransformTopicFromRaRealtime
);
import { hydraDataProvider } from "@api-platform/admin";
import { addRealTimeMethodsBasedOnApiPlatform } from "@react-admin/ra-realtime";
const dataProvider = hydraDataProvider({
entrypoint: "https://localhost",
});
function myTransformTopicFromRaRealtime(topic) {
const [_basename, _resourcePrefix, resource, ...id] = topic.split("/");
if (!id || id.length === 0) {
return `/${resource}/{id}`;
}
const originId = id[2];
return `/${resource}/${originId}`;
}
const realTimeDataProvider = addRealTimeMethodsBasedOnApiPlatform(
dataProvider,
"https://localhost/.well-known/mercure",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.obDjwCgqtPuIvwBlTxUEmibbBf0zypKCNzNKP7Op2UM",
"https://localhost",
// Pass the custom transformTopicFromRaRealtime function here
myTransformTopicFromRaRealtime
);
Mercure Adapter
The ra-realtime
package contains a function augmenting a regular (API-based) dataProvider
with real-time methods based on a Mercure hub. Use it as follows:
import { addRealTimeMethodsBasedOnMercure } from '@react-admin/ra-core-ee';
import { CoreAdmin } from 'ra-core';
const realTimeDataProvider = addRealTimeMethodsBasedOnMercure(
// original dataProvider
dataProvider,
// Mercure hub URL
'http://path.to.my.api/.well-known/mercure',
// JWT token to authenticate against the Mercure Hub
'eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOlsiKiJdfX0.SWKHNF9wneXTSjBg81YN5iH8Xb2iTf_JwhfUY5Iyhsw'
);
const App = () => (
<CoreAdmin dataProvider={realTimeDataProvider}>{/* ... */}</CoreAdmin>
);
import { addRealTimeMethodsBasedOnMercure } from "@react-admin/ra-core-ee";
import { CoreAdmin } from "ra-core";
const realTimeDataProvider = addRealTimeMethodsBasedOnMercure(
// original dataProvider
dataProvider,
// Mercure hub URL
"http://path.to.my.api/.well-known/mercure",
// JWT token to authenticate against the Mercure Hub
"eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdLCJzdWJzY3JpYmUiOlsiKiJdfX0.SWKHNF9wneXTSjBg81YN5iH8Xb2iTf_JwhfUY5Iyhsw"
);
const App = () => <CoreAdmin dataProvider={realTimeDataProvider}>{/* ... */}</CoreAdmin>;
Writing a Custom Adapter
If you're using another transport for real-time messages (WebSockets, long polling, GraphQL subscriptions, etc.), you'll have to implement subscribe
, unsubscribe
, and publish
yourself in your dataProvider
. As an example, here is an implementation using a local variable, that ra-realtime
uses in tests:
let subscriptions = [];
const dataProvider = {
// regular dataProvider methods like getList, getOne, etc,
// ...
subscribe: async (topic, subscriptionCallback) => {
subscriptions.push({ topic, subscriptionCallback });
return Promise.resolve({ data: null });
},
unsubscribe: async (topic, subscriptionCallback) => {
subscriptions = subscriptions.filter(
subscription =>
subscription.topic !== topic ||
subscription.subscriptionCallback !== subscriptionCallback
);
return Promise.resolve({ data: null });
},
publish: (topic, event) => {
if (!topic) {
return Promise.reject(new Error('missing topic'));
}
if (!event.type) {
return Promise.reject(new Error('missing event type'));
}
subscriptions.map(
subscription =>
topic === subscription.topic &&
subscription.subscriptionCallback(event)
);
return Promise.resolve({ data: null });
},
};
let subscriptions = [];
const dataProvider = {
// regular dataProvider methods like getList, getOne, etc,
// ...
subscribe: async (topic, subscriptionCallback) => {
subscriptions.push({ topic, subscriptionCallback });
return Promise.resolve({ data: null });
},
unsubscribe: async (topic, subscriptionCallback) => {
subscriptions = subscriptions.filter(
(subscription) => subscription.topic !== topic || subscription.subscriptionCallback !== subscriptionCallback
);
return Promise.resolve({ data: null });
},
publish: (topic, event) => {
if (!topic) {
return Promise.reject(new Error("missing topic"));
}
if (!event.type) {
return Promise.reject(new Error("missing event type"));
}
subscriptions.map((subscription) => topic === subscription.topic && subscription.subscriptionCallback(event));
return Promise.resolve({ data: null });
},
};
You can check the behavior of the real-time components by using the default console logging provided in addRealTimeMethodsInLocalBrowser
.
Topic And Event Format
You've noticed that all the dataProvider
real-time methods expect a topic
as the first argument. A topic
is just a string, identifying a particular real-time channel. Topics can be used e.g. to dispatch messages to different rooms in a chat application or to identify changes related to a particular record.
Most ra-realtime
components deal with CRUD logic, so ra-realtime
subscribes to special topics named resource/[name]
and resource/[name]/[id]
. For your own events, use any topic
you want.
The event
is the name of the message sent from publishers to subscribers. An event
should be a JavaScript object with a type
and a payload
field.
Here is an example event:
{
type: 'created',
payload: 'New message',
}
For CRUD operations, ra-realtime
expects events to use the types 'created', 'updated', and 'deleted'.
CRUD Events
Ra-realtime has deep integration with react-admin, where most of the logic concerns Creation, Update or Deletion (CRUD) of records. To enable this integration, your real-time backend should publish the following events:
- when a new record is created:
{
topic: `resource/${resource}`,
event: {
type: 'created',
payload: { ids: [id]},
},
}
- when a record is updated:
{
topic: `resource/${resource}/id`,
event: {
type: 'updated',
payload: { ids: [id]},
},
}
{
topic: `resource/${resource}`,
event: {
type: 'updated',
payload: { ids: [id]},
},
}
- when a record is deleted:
{
topic: `resource/${resource}/id`,
event: {
type: 'deleted',
payload: { ids: [id]},
},
}
{
topic: `resource/${resource}`,
event: {
type: 'deleted',
payload: { ids: [id]},
},
}
Lock Format
A lock
stores the record that is locked, the identity of the locker, and the time at which the lock was acquired. It is used to prevent concurrent editing of the same record. A typical lock looks like this:
{
resource: 'posts',
recordId: 123,
identity: 'julien',
createdAt: '2023-01-02T21:36:35.133Z',
}
The dataProvider.getLock()
and dataProvider.getLocks()
methods should return these locks.
As for the mutation methods (dataProvider.lock()
, dataProvider.unlock()
), they expect the following parameters:
resource
: the resource name (e.g.'posts'
)params
: an object containing the followingid
: the record id (e.g.123
)identity
: an identifier (string or number) corresponding to the identity of the locker (e.g.'julien'
). This could be an authentication token for instance.meta
: an object that will be forwarded to the dataProvider (optional)
Locks Based On A Lock Resource
The ra-realtime
package offers a function augmenting a regular (API-based) dataProvider
with locks methods based on a locks
resource.
It will translate a dataProvider.getLocks()
call to a dataProvider.getList('locks')
call, and a dataProvider.lock()
call to a dataProvider.create('locks')
call.
The lock
resource should contain the following fields:
{
"id": 123,
"identity": "Toad",
"resource": "people",
"recordId": 18,
"createdAt": "2020-09-29 10:20"
}
Please note that the identity
and the createdAt
formats depend on your API.
Here is how to use it in your react-admin application:
import { CoreAdmin } from 'ra-core';
import { addLocksMethodsBasedOnALockResource } from '@react-admin/ra-core-ee';
const dataProviderWithLocks = addLocksMethodsBasedOnALockResource(
dataProvider // original dataProvider
);
const App = () => (
<CoreAdmin dataProvider={dataProviderWithLocks}>{/* ... */}</CoreAdmin>
);
import { CoreAdmin } from "ra-core";
import { addLocksMethodsBasedOnALockResource } from "@react-admin/ra-core-ee";
const dataProviderWithLocks = addLocksMethodsBasedOnALockResource(
dataProvider // original dataProvider
);
const App = () => <CoreAdmin dataProvider={dataProviderWithLocks}>{/* ... */}</CoreAdmin>;
dataProvider
Methods Directly
Calling the Once you've set a real-time dataProvider
, you can call the real-time methods in your React components via the useDataProvider
hook.
For instance, here is a component displaying messages posted to the 'messages' topic in real time:
import React, { useState } from 'react';
import { useDataProvider, useNotify } from 'ra-core';
const MessageList = () => {
const notify = useNotify();
const [messages, setMessages] = useState([]);
const dataProvider = useDataProvider();
useEffect(() => {
const callback = event => {
// event is like
// {
// topic: 'messages',
// type: 'created',
// payload: 'New message',
// }
setMessages(messages => [...messages, event.payload]);
notify('New message');
};
// subscribe to the 'messages' topic on mount
dataProvider.subscribe('messages', callback);
// unsubscribe on unmount
return () => dataProvider.unsubscribe('messages', callback);
}, [setMessages, notify, dataProvider]);
return (
<ul>
{messages.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
);
};
import React, { useState } from "react";
import { useDataProvider, useNotify } from "ra-core";
const MessageList = () => {
const notify = useNotify();
const [messages, setMessages] = useState([]);
const dataProvider = useDataProvider();
useEffect(() => {
const callback = (event) => {
// event is like
// {
// topic: 'messages',
// type: 'created',
// payload: 'New message',
// }
setMessages((messages) => [...messages, event.payload]);
notify("New message");
};
// subscribe to the 'messages' topic on mount
dataProvider.subscribe("messages", callback);
// unsubscribe on unmount
return () => dataProvider.unsubscribe("messages", callback);
}, [setMessages, notify, dataProvider]);
return (
<ul>
{messages.map((message, index) => (
<li key={index}>{message}</li>
))}
</ul>
);
};
And here is a button for publishing an event to the messages
topic. All the subscribers to this topic will execute their callback:
import React from 'react';
import { useDataProvider, useNotify } from 'ra-core';
const SendMessageButton = () => {
const dataProvider = useDataProvider();
const notify = useNotify();
const handleClick = () => {
dataProvider
.publish('messages', { type: 'created', payload: 'New message' })
.then(() => notify('Message sent'));
};
return <button onClick={handleClick}>Send new message</button>;
};
import React from "react";
import { useDataProvider, useNotify } from "ra-core";
const SendMessageButton = () => {
const dataProvider = useDataProvider();
const notify = useNotify();
const handleClick = () => {
dataProvider
.publish("messages", { type: "created", payload: "New message" })
.then(() => notify("Message sent"));
};
return <button onClick={handleClick}>Send new message</button>;
};
Tip: You should not need to call publish()
directly very often. Most real-time backends publish events in reaction to a change in the data. So the previous example is fictive. In reality, a typical <SendMessageButton>
would simply call dataProvider.create('messages')
, and the API would create the new message AND publish the 'created' event to the real-time bus.
Hooks
usePublish
Get a callback to publish an event on a topic. The callback returns a promise that resolves when the event is published.
usePublish
calls dataProvider.publish()
to publish the event. It leverages react-query's useMutation
hook to provide a callback.
Note: Events should generally be published by the server, in reaction to an action by an end user. They should seldom be published directly by the client. This hook is provided mostly for testing purposes, but you may use it in your own custom components if you know what you're doing.
Usage
usePublish
returns a callback with the following signature:
const publish = usePublish();
publish(topic, event, options);
const publish = usePublish();
publish(topic, event, options);
For instance, in a chat application, when a user is typing a message, the following component publishes a typing
event to the chat/[channel]
topic:
import { useInput, useGetIdentity } from 'ra-core';
import { usePublish } from '@react-admin/ra-core-ee';
const MessageInput = ({ channel }) => {
const [publish, { isLoading }] = usePublish();
const { id, field, fieldState } = useInput({ source: 'message' });
const { identity } = useGetIdentity();
const handleUserInput = event => {
publish(`chat/${channel}`, {
type: 'typing',
payload: { user: identity },
});
};
return (
<label htmlFor={id}>
Type your message
<input id={id} {...field} onInput={handleUserInput} />
</label>
);
};
import { useInput, useGetIdentity } from "ra-core";
import { usePublish } from "@react-admin/ra-core-ee";
const MessageInput = ({ channel }) => {
const [publish, { isLoading }] = usePublish();
const { id, field, fieldState } = useInput({ source: "message" });
const { identity } = useGetIdentity();
const handleUserInput = (event) => {
publish(`chat/${channel}`, {
type: "typing",
payload: { user: identity },
});
};
return (
<label htmlFor={id}>
Type your message
<input id={id} {...field} onInput={handleUserInput} />
</label>
);
};
The event format is up to you. It should at least contain a type
property and may contain a payload
property. The payload
property can contain any data you want to send to the subscribers.
Some hooks and components in this package are specialized to handle "CRUD" events, which are events with a type
property set to created
, updated
or deleted
. For instance:
{
topic: `resource/${resource}/id`,
event: {
type: 'deleted',
payload: { ids: [id]},
},
}
See the CRUD events section for more details.
Return Value
usePublish
returns an array with the following values:
publish
: The callback to publish an event to a topic.state
: The state of the mutation (see react-query documentation). Notable properties:isLoading
: Whether the mutation is loading.error
: The error if the mutation failed.data
: The published event if the mutation succeeded.
const [publish, { isLoading, error, data }] = usePublish();
const [publish, { isLoading, error, data }] = usePublish();
Callback Parameters
The publish
callback accepts the following parameters:
topic
: The topic to publish the event on.event
: The event to publish. It must contain atype
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 to0
.
const [publish] = usePublish();
publish(
'chat/general',
{
type: 'message',
payload: { user: 'John', message: 'Hello!' },
},
{
onSuccess: event => console.log('Event published', event),
onError: error => console.log('Could not publish event', error),
retry: 3,
}
);
const [publish] = usePublish();
publish(
"chat/general",
{
type: "message",
payload: { user: "John", message: "Hello!" },
},
{
onSuccess: (event) => console.log("Event published", event),
onError: (error) => console.log("Could not publish event", error),
retry: 3,
}
);
useSubscribe
Subscribe to the events from a topic on mount (and unsubscribe on unmount).
Usage
The following component subscribes to the messages/{channelName}
topic and displays a badge with the number of unread messages:
import { useState, useCallback } from 'react';
import { useSubscribe } from '@react-admin/ra-core-ee';
const ChannelName = ({ name }) => {
const [nbMessages, setNbMessages] = useState(0);
const callback = useCallback(
event => {
if (event.type === 'created') {
setNbMessages(count => count + 1);
}
},
[setNbMessages]
);
useSubscribe(`messages/${name}`, callback);
return nbMessages > 0 ? (
<p>#{name} ({nbMessages} new messages)</p>
) : (
<p>#{name}</p>
);
};
import { useState, useCallback } from "react";
import { useSubscribe } from "@react-admin/ra-core-ee";
const ChannelName = ({ name }) => {
const [nbMessages, setNbMessages] = useState(0);
const callback = useCallback(
(event) => {
if (event.type === "created") {
setNbMessages((count) => count + 1);
}
},
[setNbMessages]
);
useSubscribe(`messages/${name}`, callback);
return nbMessages > 0 ? (
<p>
#{name} ({nbMessages} new messages)
</p>
) : (
<p>#{name}</p>
);
};
Parameters
Prop | Required | Type | Default | Description |
---|---|---|---|---|
topic |
Optional | string |
- | The topic to subscribe to. When empty, no subscription is created. |
callback |
Optional | function |
- | The callback to execute when an event is received. |
options |
Optional | object |
- | Options to modify the subscription / unsubscription behavior. |
callback
This function will be called with the event as its first argument, so you can use it to update the UI.
useSubscribe(`messages/${name}`, event => {
if (event.type === 'created') {
setNbMessages(count => count + 1);
}
});
useSubscribe(`messages/${name}`, (event) => {
if (event.type === "created") {
setNbMessages((count) => count + 1);
}
});
Tip: Memoize the callback using useCallback
to avoid unnecessary subscriptions/unsubscriptions.
const callback = useCallback(
event => {
if (event.type === 'created') {
setNbMessages(count => count + 1);
}
},
[setNbMessages]
);
useSubscribe(`messages/${name}`, callback);
const callback = useCallback(
(event) => {
if (event.type === "created") {
setNbMessages((count) => count + 1);
}
},
[setNbMessages]
);
useSubscribe(`messages/${name}`, callback);
The callback function receives an unsubscribe
callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.
import { useState, useCallback } from 'react';
import { useSubscribe } from '@react-admin/ra-core-ee';
const JobProgress = ({ jobId }) => {
const [progress, setProgress] = useState(0);
const callback = useCallback(
(event, unsubscribe) => {
if (event.type === 'progress') {
setProgress(event.payload.progress);
}
if (event.type === 'completed') {
unsubscribe();
}
},
[setColor]
);
useSubscribe(`jobs/${jobId}`, callback);
return (
<div>{progress}%</div>
);
};
import { useState, useCallback } from "react";
import { useSubscribe } from "@react-admin/ra-core-ee";
const JobProgress = ({ jobId }) => {
const [progress, setProgress] = useState(0);
const callback = useCallback(
(event, unsubscribe) => {
if (event.type === "progress") {
setProgress(event.payload.progress);
}
if (event.type === "completed") {
unsubscribe();
}
},
[setColor]
);
useSubscribe(`jobs/${jobId}`, callback);
return <div>{progress}%</div>;
};
options
The options
object can contain the following properties:
enabled
: Whether to subscribe or not. Defaults totrue
once
: Whether to unsubscribe after the first event. Defaults tofalse
.unsubscribeOnUnmount
: Whether to unsubscribe on unmount. Defaults totrue
.
You can use the once
option to subscribe to a topic only once, and then unsubscribe.
For instance, the following component subscribes to the office/restart
topic and changes the message when the office is open, then unsubscribes from the topic:
import { useState } from 'react';
import { useSubscribe } from '@react-admin/ra-core-ee';
const OfficeClosed = () => {
const [state, setState] = useState('closed');
useSubscribe('office/restart', () => setState('open'), { once: true });
return (
<div>
{state === 'closed'
? 'Sorry, the office is closed for maintenance.'
: 'Welcome! The office is open.'}
</div>
);
};
import { useState } from "react";
import { useSubscribe } from "@react-admin/ra-core-ee";
const OfficeClosed = () => {
const [state, setState] = useState("closed");
useSubscribe("office/restart", () => setState("open"), { once: true });
return (
<div>
{state === "closed" ? "Sorry, the office is closed for maintenance." : "Welcome! The office is open."}
</div>
);
};
topic
The first argument of useSubscribe
is the topic to subscribe to. It can be an arbitrary string.
useSubscribe('messages', event => {
// ...
});
useSubscribe("messages", (event) => {
// ...
});
If you want to subscribe to CRUD events, instead of writing the topic manually like resource/[resource]
, you can use the useSubscribeToRecord
or useSubscribeToRecordList
hooks.
useSubscribeCallback
Get a callback to subscribe to events on a topic and optionally unsubscribe on unmount.
This is useful to start a subscription from an event handler, like a button click.
Usage
The following component subscribes to the backgroundJobs/recompute
topic on click, and displays the progress of the background job:
import { useState, useCallback } from 'react';
import { useDataProvider } from 'ra-core';
import { useSubscribeCallback } from '@react-admin/ra-core-ee';
const LaunchBackgroundJob = () => {
const dataProvider = useDataProvider();
const [progress, setProgress] = useState(0);
const callback = useCallback(
(event, unsubscribe) => {
setProgress(event.payload?.progress || 0);
if (event.payload?.progress === 100) {
unsubscribe();
}
},
[setProgress]
);
const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
callback
);
return (
<div>
<button
onClick={() => {
subscribe();
dataProvider.recompute();
}}
>
{progress === 0 ? 'Launch recompute' : `Recompute in progress (${progress}%)`}
</button>
</div>
);
};
import { useState, useCallback } from "react";
import { useDataProvider } from "ra-core";
import { useSubscribeCallback } from "@react-admin/ra-core-ee";
const LaunchBackgroundJob = () => {
const dataProvider = useDataProvider();
const [progress, setProgress] = useState(0);
const callback = useCallback(
(event, unsubscribe) => {
setProgress(event.payload?.progress || 0);
if (event.payload?.progress === 100) {
unsubscribe();
}
},
[setProgress]
);
const subscribe = useSubscribeCallback("backgroundJobs/recompute", callback);
return (
<div>
<button
onClick={() => {
subscribe();
dataProvider.recompute();
}}
>
{progress === 0 ? "Launch recompute" : `Recompute in progress (${progress}%)`}
</button>
</div>
);
};
Parameters
Prop | Required | Type | Default | Description |
---|---|---|---|---|
topic |
Optional | string |
- | The topic to subscribe to. When empty, no subscription is created. |
callback |
Optional | function |
- | The callback to execute when an event is received. |
options |
Optional | object |
- | Options to modify the subscription / unsubscription behavior. |
callback
Whenever an event is published on the topic
passed as the first argument, the function passed as the second argument will be called with the event as a parameter.
const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => {
if (event.type === 'progress') {
setProgress(event.payload.progress);
}
});
// later
subscribe();
const subscribe = useSubscribeCallback("backgroundJobs/recompute", (event) => {
if (event.type === "progress") {
setProgress(event.payload.progress);
}
});
// later
subscribe();
Tip: Memoize the callback using useCallback
to avoid unnecessary subscriptions/unsubscriptions.
const callback = useCallback(
event => {
if (event.type === 'progress') {
setProgress(event.payload.progress);
}
},
[setProgress]
);
const callback = useCallback(
(event) => {
if (event.type === "progress") {
setProgress(event.payload.progress);
}
},
[setProgress]
);
The callback function receives an unsubscribe
callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.
const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
(event, unsubscribe) => {
if (event.type === 'completed') {
setProgress(100);
unsubscribe();
}
}
);
const subscribe = useSubscribeCallback("backgroundJobs/recompute", (event, unsubscribe) => {
if (event.type === "completed") {
setProgress(100);
unsubscribe();
}
});
options
The options
object can contain the following properties:
enabled
: Whether to subscribe or not. Defaults totrue
once
: Whether to unsubscribe after the first event. Defaults tofalse
.unsubscribeOnUnmount
: Whether to unsubscribe on unmount. Defaults totrue
.
You can use the once
option to subscribe to a topic only once, and then unsubscribe.
For instance, the following component subscribes to the backgroundJobs/recompute
topic on click, displays a notification when the background job is complete, then unsubscribes:
import { useDataProvider, useNotify } from 'ra-core';
import { useSubscribeCallback } from '@react-admin/ra-core-ee';
const LaunchBackgroundJob = () => {
const dataProvider = useDataProvider();
const notify = useNotify();
const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
event =>
notify('Recompute complete: %{summary}', {
type: 'success',
messageArgs: {
summary: event.payload?.summary,
},
}),
{
unsubscribeOnUnmount: false, // show the notification even if the user navigates away
once: true, // unsubscribe after the first event
}
);
return (
<button
onClick={() => {
subscribe();
dataProvider.recompute();
}}
>
Launch background job
</button>
);
};
You can use the unsubscribeOnUnmount
option to keep the subscription alive after the component unmounts.
This can be useful when you want the subscription to persist across multiple pages.
const subscribe = useSubscribeCallback(
'backgroundJobs/recompute',
event => setProgress(event.payload?.progress || 0),
{
unsubscribeOnUnmount: false, // don't unsubscribe on unmount
}
);
const subscribe = useSubscribeCallback(
"backgroundJobs/recompute",
(event) => setProgress(event.payload?.progress || 0),
{
unsubscribeOnUnmount: false, // don't unsubscribe on unmount
}
);
topic
The first argument of useSubscribeCallback
is the topic to subscribe to. It can be an arbitrary string.
const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => {
// ...
});
// later
subscribe();
const subscribe = useSubscribeCallback("backgroundJobs/recompute", (event) => {
// ...
});
// later
subscribe();
useSubscribeToRecord
This specialized version of useSubscribe
subscribes to events concerning a single record.
Usage
The hook expects a callback function as its only argument, as it guesses the record and resource from the current context. The callback will be executed whenever an event is published on the resource/[resource]/[recordId]
topic.
For instance, the following component displays a message when the record is updated by someone else:
const WarnWhenUpdatedBySomeoneElse = () => {
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const handleClose = () => {
setOpen(false);
};
const { refetch } = useEditContext();
const refresh = () => {
refetch();
handleClose();
};
const {
formState: { isDirty },
} = useFormContext();
useSubscribeToRecord((event: Event) => {
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
return open ? (
<div className="flex flex-col gap-4">
<p>
Post Updated by {author}
</p>
<p>
Your changes and their changes may conflict. What do you
want to do?
</p>
<div className="flex gap-4">
<button onClick={handleClose}>Keep my changes</button>
<button onClick={refresh}>
Get their changes (and lose mine)
</button>
</div>
</div>
) : null;
};
const PostEdit = () => (
<EditBase>
<Form>
{/* Inputs... */}
<WarnWhenUpdatedBySomeoneElse />
</Form>
</EditBase>
);
const WarnWhenUpdatedBySomeoneElse = () => {
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState(null);
const handleClose = () => {
setOpen(false);
};
const { refetch } = useEditContext();
const refresh = () => {
refetch();
handleClose();
};
const {
formState: { isDirty },
} = useFormContext();
useSubscribeToRecord((event) => {
if (event.type === "edited") {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
return open ? (
<div className="flex flex-col gap-4">
<p>Post Updated by {author}</p>
<p>Your changes and their changes may conflict. What do you want to do?</p>
<div className="flex gap-4">
<button onClick={handleClose}>Keep my changes</button>
<button onClick={refresh}>Get their changes (and lose mine)</button>
</div>
</div>
) : null;
};
const PostEdit = () => (
<EditBase>
<Form>
{/* Inputs... */}
<WarnWhenUpdatedBySomeoneElse />
</Form>
</EditBase>
);
useSubscribeToRecord
reads the current resource and record from the ResourceContext
and RecordContext
respectively. In the example above, the notification is displayed when the app receives an event on the resource/books/123
topic.
Just like useSubscribe
, useSubscribeToRecord
unsubscribes from the topic when the component unmounts.
Tip: In the example above, <Show>
creates the RecordContext
- that's why the useSubscribeToRecord
hook is used in its child component instead of in the <BookShow>
component.
You can provide the resource and record id explicitly if you are not in such contexts:
useSubscribeToRecord(
event => {
/* ... */
},
'posts',
123
);
useSubscribeToRecord(
(event) => {
/* ... */
},
"posts",
123
);
Tip: If your reason to subscribe to events on a record is to keep the record up to date, you should use the useGetOneLive
hook instead.
Parameters
Prop | Required | Type | Default | Description |
---|---|---|---|---|
callback |
Required | function |
- | The callback to execute when an event is received. |
resource |
Optional | string |
- | The resource to subscribe to. Defaults to the resource in the ResourceContext . |
recordId |
Optional | string |
- | The record id to subscribe to. Defaults to the id of the record in the RecordContext . |
options |
Optional | object |
- | The subscription options. |
callback
Whenever an event is published on the resource/[resource]/[recordId]
topic, the function passed as the first argument will be called with the event as a parameter.
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const { refetch } = useEditContext();
const {
formState: { isDirty },
} = useFormContext();
useSubscribeToRecord((event: Event) => {
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState(null);
const { refetch } = useEditContext();
const {
formState: { isDirty },
} = useFormContext();
useSubscribeToRecord((event) => {
if (event.type === "edited") {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
Tip: Memoize the callback using useCallback
to avoid unnecessary subscriptions/unsubscriptions.
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState<string | null>(null);
const { refetch } = useEditContext();
const {
formState: { isDirty },
} = useFormContext();
const handleEvent = useCallback(
(event: Event) => {
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
},
[isDirty, refetch, setOpen, setAuthor]
);
useSubscribeToRecord(handleEvent);
const [open, setOpen] = useState(false);
const [author, setAuthor] = useState(null);
const { refetch } = useEditContext();
const {
formState: { isDirty },
} = useFormContext();
const handleEvent = useCallback(
(event) => {
if (event.type === "edited") {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
},
[isDirty, refetch, setOpen, setAuthor]
);
useSubscribeToRecord(handleEvent);
Just like for useSubscribe
, the callback function receives an unsubscribe
callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.
useSubscribeToRecord((event: Event, unsubscribe) => {
if (event.type === 'deleted') {
// do something
unsubscribe();
}
if (event.type === 'edited') {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
useSubscribeToRecord((event, unsubscribe) => {
if (event.type === "deleted") {
// do something
unsubscribe();
}
if (event.type === "edited") {
if (isDirty) {
setOpen(true);
setAuthor(event.payload.user);
} else {
refetch();
}
}
});
options
The options
object can contain the following properties:
enabled
: Whether to subscribe or not. Defaults totrue
once
: Whether to unsubscribe after the first event. Defaults tofalse
.unsubscribeOnUnmount
: Whether to unsubscribe on unmount. Defaults totrue
.
See useSubscribe
for more details.
recordId
The record id to subscribe to. By default, useSubscribeToRecord
builds the topic it subscribes to using the id of the record in the RecordContext
. But you can override this behavior by passing a record id as the third argument.
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
event => {
/* ... */
},
'posts',
123
);
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
(event) => {
/* ... */
},
"posts",
123
);
Note that if you pass a null record id, the hook will not subscribe to any topic.
resource
The resource to subscribe to. By default, useSubscribeToRecord
builds the topic it subscribes to using the resource in the ResourceContext
. But you can override this behavior by passing a resource name as the second argument.
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
event => {
/* ... */
},
'posts',
123
);
// will subscribe to the 'resource/posts/123' topic
useSubscribeToRecord(
(event) => {
/* ... */
},
"posts",
123
);
Note that if you pass an empty string as the resource name, the hook will not subscribe to any topic.
useSubscribeToRecordList
This specialized version of useSubscribe
subscribes to events concerning a list of records.
Usage
useSubscribeToRecordList
expects a callback function as its first argument. It will be executed whenever an event is published on the resource/[resource]
topic.
For instance, the following component displays notifications when a record is created, updated, or deleted by someone else:
import React from 'react';
import { useNotify, useListContext } from 'ra-core';
import { useSubscribeToRecordList } from '@react-admin/ra-core-ee';
const ListWatcher = () => {
const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList(event => {
switch (event.type) {
case 'created': {
notity('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
});
return null;
};
const MovieList = () => (
<ListBase>
{/* The list view*/}
<ListWatcher />
</ListBase>
);
import React from "react";
import { useNotify, useListContext } from "ra-core";
import { useSubscribeToRecordList } from "@react-admin/ra-core-ee";
const ListWatcher = () => {
const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList((event) => {
switch (event.type) {
case "created": {
notity("New movie created");
refetch();
break;
}
case "updated": {
if (data.find((record) => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case "deleted": {
if (data.find((record) => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
});
return null;
};
const MovieList = () => (
<ListBase>
{/* The list view*/}
<ListWatcher />
</ListBase>
);
Parameters
Prop | Required | Type | Default | Description |
---|---|---|---|---|
callback |
Required | function |
- | The callback function to execute when an event is published on the topic. |
resource |
Optional | string |
- | The resource to subscribe to. Defaults to the resource in the ResourceContext . |
options |
Optional | object |
- | The subscription options. |
callback
Whenever an event is published on the resource/[resource]
topic, the function passed as the first argument will be called with the event as a parameter.
const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList(event => {
switch (event.type) {
case 'created': {
notity('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
});
const notity = useNotify();
const { refetch, data } = useListContext();
useSubscribeToRecordList((event) => {
switch (event.type) {
case "created": {
notity("New movie created");
refetch();
break;
}
case "updated": {
if (data.find((record) => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case "deleted": {
if (data.find((record) => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
});
Tip: Memoize the callback using useCallback
to avoid unnecessary subscriptions/unsubscriptions.
const notity = useNotify();
const { refetch, data } = useListContext();
const callback = useCallback(
event => {
switch (event.type) {
case 'created': {
notity('New movie created');
refetch();
break;
}
case 'updated': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case 'deleted': {
if (data.find(record => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
},
[data, refetch, notity]
);
useSubscribeToRecordList(callback);
const notity = useNotify();
const { refetch, data } = useListContext();
const callback = useCallback(
(event) => {
switch (event.type) {
case "created": {
notity("New movie created");
refetch();
break;
}
case "updated": {
if (data.find((record) => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} updated`);
refetch();
}
break;
}
case "deleted": {
if (data.find((record) => record.id === event.payload.ids[0])) {
notity(`Movie #${event.payload.ids[0]} deleted`);
refetch();
}
break;
}
}
},
[data, refetch, notity]
);
useSubscribeToRecordList(callback);
Just like for useSubscribe
, the callback function receives an unsubscribe
callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event.
options
The options
object can contain the following properties:
enabled
: Whether to subscribe or not. Defaults totrue
once
: Whether to unsubscribe after the first event. Defaults tofalse
.unsubscribeOnUnmount
: Whether to unsubscribe on unmount. Defaults totrue
.
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 fromauthProvider.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 fromauthProvider.getIdentity()
meta
: an object that will be forwarded to the dataProvider (optional)
The optional options
argument is passed to react-query's useMutation
hook.
useGetLock
Gets the lock status for a record. It calls dataProvider.getLock()
on mount.
const { data, isLoading } = useGetLock(resource, { id });
const { data, isLoading } = useGetLock(resource, { id });
Parameters description:
resource
: the resource name (e.g.'posts'
)params
: an object with the following properties:id
: the record id (e.g.123
)meta
: Optional. an object that will be forwarded to the dataProvider (optional)
Here is a form toolbar that displays the lock status of the current record:
const FormToolbar = () => {
const resource = useResourceContext();
const record = useRecordContext();
const { isLoading: identityLoading, identity } = useGetIdentity();
const { isLoading: lockLoading, data: lock } = useGetLock(resource, {
id: record.id,
});
if (identityLoading || lockLoading) {
return null;
}
const isLockedByOtherUser = lock?.identity !== identity.id;
return (
<div className="flex items-center gap-4">
<button type="submit" disabled={isLockedByOtherUser}>Save</button>
{isLockedByOtherUser && (
<span>
{`This record is locked by another user: ${lock?.dentity}.`}
</span>
)}
</div>
);
};
const FormToolbar = () => {
const resource = useResourceContext();
const record = useRecordContext();
const { isLoading: identityLoading, identity } = useGetIdentity();
const { isLoading: lockLoading, data: lock } = useGetLock(resource, {
id: record.id,
});
if (identityLoading || lockLoading) {
return null;
}
const isLockedByOtherUser = lock?.identity !== identity.id;
return (
<div className="flex items-center gap-4">
<button type="submit" disabled={isLockedByOtherUser}>
Save
</button>
{isLockedByOtherUser && <span>{`This record is locked by another user: ${lock?.dentity}.`}</span>}
</div>
);
};
useGetLockLive
Use the useGetLockLive()
hook to get the lock status in real time. This hook calls dataProvider.getLock()
for the current record on mount, and subscribes to live updates on the lock/[resource]/[id]
topic.
This means that if the lock is acquired or released by another user while the current user is on the page, the return value will be updated.
import { useGetLockLive } from '@react-admin/ra-core-ee';
const LockStatus = () => {
const { data: lock } = useGetLockLive();
const { identity } = useGetIdentity();
if (!lock) return <span>No lock</span>;
if (lock.identity === identity?.id) return <span>Locked by you</span>;
return <span>Locked by {lock.identity}</span>;
};
import { useGetLockLive } from "@react-admin/ra-core-ee";
const LockStatus = () => {
const { data: lock } = useGetLockLive();
const { identity } = useGetIdentity();
if (!lock) return <span>No lock</span>;
if (lock.identity === identity?.id) return <span>Locked by you</span>;
return <span>Locked by {lock.identity}</span>;
};
useGetLockLive
reads the current resource and record id from the ResourceContext
and RecordContext
. You can provide them explicitly if you are not in such a context:
const { data: lock } = useGetLockLive('posts', { id: 123 });
const { data: lock } = useGetLockLive("posts", { id: 123 });
useGetLocks
Get all the locks for a given resource. Calls dataProvider.getLocks()
on mount.
// simple Usage
const { data } = useGetLocks('posts');
// simple Usage
const { data } = useGetLocks("posts");
Here is how to use it in a custom list, to disable edit and delete buttons for locked records:
import { WithListContext, useRecordContext } from 'ra-core';
import { useGetLocks, type Lock } from '@react-admin/ra-core-ee';
import { DeleteButton } from '@components/ui/DeleteButton';
import { LockableEditButton } from '@components/ui/DeleteButton';
const MyPostGrid = () => {
const resource = useResourceContext();
const { data: locks } = useGetLocks(resource);
return (
<ul>
<WithListContext
render={({ data, isPending }) => isPending ? null : (
<li className="flex justify-space-between">
<MyPostTitle locks={locks} />
<MyPostActions locks={locks} />
</li>
)}
/>
</ul>
);
};
const MyPostTitle = ({ locks }: { locks: Lock[] }) => {
const record = useRecordContext();
const lock = locks.find(l => l.recordId === record.id);
return (
<div className="flex gap-4">
<WithRecord label="title" render={record => <span>{record.title}</span>} />} />
{lock && (
<span style={{ color: 'red' }}>
{` (Locked by ${lock.identity})`}
</span>
)}
</div>
);
};
const MyPostActions = ({ locks }: { locks: Lock[] }) => {
const record = useRecordContext();
const locked = locks.find(l => l.recordId === record.id);
return (
<div className="flex gap-4">
<DeleteButton disabled={!!locked} />
<LockableEditButton disabled={!!locked} />
</div>
);
};
import { WithListContext, useRecordContext } from "ra-core";
import { useGetLocks } from "@react-admin/ra-core-ee";
import { DeleteButton } from "@components/ui/DeleteButton";
import { LockableEditButton } from "@components/ui/DeleteButton";
const MyPostGrid = () => {
const resource = useResourceContext();
const { data: locks } = useGetLocks(resource);
return (
<ul>
<WithListContext
render={({ data, isPending }) =>
isPending ? null : (
<li className="flex justify-space-between">
<MyPostTitle locks={locks} />
<MyPostActions locks={locks} />
</li>
)
}
/>
</ul>
);
};
const MyPostTitle = ({ locks }) => {
const record = useRecordContext();
const lock = locks.find((l) => l.recordId === record.id);
return (
<div className="flex gap-4">
<WithRecord label="title" render={(record) => <span>{record.title}</span>} />} />
{lock && <span style={{ color: "red" }}>{` (Locked by ${lock.identity})`}</span>}
</div>
);
};
const MyPostActions = ({ locks }) => {
const record = useRecordContext();
const locked = locks.find((l) => l.recordId === record.id);
return (
<div className="flex gap-4">
<DeleteButton disabled={!!locked} />
<LockableEditButton disabled={!!locked} />
</div>
);
};
useGetLocksLive
Use the useGetLocksLive
hook to get the locks in real time. This hook calls dataProvider.getLocks()
for the current resource on mount, and subscribes to live updates on the lock/[resource]
topic.
This means that if a lock is acquired or released by another user while the current user is on the page, the return value will be updated.
import { useRecordContext } from 'ra-core';
import { useGetLocksLive } from '@react-admin/ra-core-ee';
import { Lock } from 'lucide-react';
export const LockField = ({ locks }) => {
const record = useRecordContext();
if (!record) return null;
const lock = locks?.find(lock => lock.recordId === record?.id);
if (!lock) return <span className="w-4 h-4" />;
return <Lock className="w-4 h-4" />;
};
import { useRecordContext } from "ra-core";
import { Lock } from "lucide-react";
export const LockField = ({ locks }) => {
const record = useRecordContext();
if (!record) return null;
const lock = locks?.find((lock) => lock.recordId === record?.id);
if (!lock) return <span className="w-4 h-4" />;
return <Lock className="w-4 h-4" />;
};
useGetLocksLive
reads the current resource from the ResourceContext
. You can provide it explicitly if you are not in such a context:
const { data: locks } = useGetLocksLive('posts');
const { data: locks } = useGetLocksLive("posts");
useLockCallbacks
This utility hook allows to easily get the callbacks to lock and unlock a record, as well as the current lock status.
Usage
Use this hook e.g. to build a lock button:
import { useLockCallbacks } from '@react-admin/ra-core-ee';
import { LoaderCircle, Lock } from 'lucide-react';
export const LockButton = () => {
const {
lock,
isLocked,
isLockedByCurrentUser,
isPending,
isLocking,
isUnlocking,
doLock,
doUnlock,
} = useLockCallbacks();
if (isPending) {
return null;
}
return isLocked ? (
isLockedByCurrentUser ? (
<button
disabled={isUnlocking}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
doUnlock();
}}
title="Locked by you, click to unlock"
>
{isUnlocking ? (
<LoaderCircle className="w-4 h-4 animate-spin" />
) : (
<Lock className="w-4 h-4" />
)}
</button>
) : (
<Lock title={`Locked by another user: ${lock?.identity}`} />
)
) : (
<button
disabled={isLocking}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
doLock();
}}
title="Record is unlocked, click to lock"
>
{isLocking ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <LockOpenIcon className="w-4 h-4" />}
</button>
);
};
import { useLockCallbacks } from "@react-admin/ra-core-ee";
import { LoaderCircle, Lock } from "lucide-react";
export const LockButton = () => {
const { lock, isLocked, isLockedByCurrentUser, isPending, isLocking, isUnlocking, doLock, doUnlock } =
useLockCallbacks();
if (isPending) {
return null;
}
return isLocked ? (
isLockedByCurrentUser ? (
<button
disabled={isUnlocking}
onClick={(e) => {
e.stopPropagation();
doUnlock();
}}
title="Locked by you, click to unlock"
>
{isUnlocking ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <Lock className="w-4 h-4" />}
</button>
) : (
<Lock title={`Locked by another user: ${lock?.identity}`} />
)
) : (
<button
disabled={isLocking}
onClick={(e) => {
e.stopPropagation();
doLock();
}}
title="Record is unlocked, click to lock"
>
{isLocking ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <LockOpenIcon className="w-4 h-4" />}
</button>
);
};
You can also leverage this hook as a quick way to access the lock status of the current record:
import { useLockCallbacks } from '@react-admin/ra-core-ee';
export const MyToolbar = () => {
const { isLockedByCurrentUser } = useLockCallbacks();
return (
<div>
<button type="submit" disabled={!isLockedByCurrentUser}>Save</button>
</div>
);
};
import { useLockCallbacks } from "@react-admin/ra-core-ee";
export const MyToolbar = () => {
const { isLockedByCurrentUser } = useLockCallbacks();
return (
<div>
<button type="submit" disabled={!isLockedByCurrentUser}>
Save
</button>
</div>
);
};
Parameters
useLockCallbacks
accepts a single options parameter, with the following properties:
Name | Required | Type | Default Value | Description |
---|---|---|---|---|
identity |
No | Identifier |
From AuthProvider.getIdentity() |
An identifier for the user who owns the lock. |
resource |
No | string |
From ResourceContext |
The resource name (e.g. 'posts' ). |
id |
No | Identifier |
From RecordContext |
The record id (e.g. 123 ). |
meta |
No | object |
- | Additional metadata forwarded to the dataProvider lock() , unlock() and getLock() calls. |
lockMutationOptions |
No | object |
- | react-query mutation options, used to customize the lock side-effects. |
unlockMutationOptions |
No | object |
- | react-query mutation options, used to customize the unlock side-effects. |
queryOptions |
No | object |
- | react-query query options, used to customize the lock query side-effects. |
You can call useLockCallbacks
with no parameter, and it will guess the resource and record id from the context (or the route):
const { isLocked, error, isLocking } = useLockCallbacks();
const { isLocked, error, isLocking } = useLockCallbacks();
Or you can provide them explicitly:
const { isLocked, error, isLocking } = useLockCallbacks({
resource: 'venues',
id: 123,
identity: 'John Doe',
});
const { isLocked, error, isLocking } = useLockCallbacks({
resource: "venues",
id: 123,
identity: "John Doe",
});
Return value
useLockCallbacks
returns an object with the following properties:
Name | Type | Description |
---|---|---|
isLocked |
boolean |
Whether the record is currently locked (possibly by another user) or not. |
isLockedByCurrentUser |
boolean |
Whether the record is locked by the current user or not. |
lock |
object |
The lock data. |
error |
object |
The error object if any of the mutations or the query fails. |
isPending |
boolean |
Whether the lock query is in progress. |
isLocking |
boolean |
Whether the lock mutation is in progress. |
isUnlocking |
boolean |
Whether the unlock mutation is in progress. |
doLock |
function |
A callback to manually lock the record. |
doUnlock |
function |
A callback to manually unlock the record. |
doLockAsync |
function |
A callback to manually lock the record asynchronously. |
doUnlockAsync |
function |
A callback to manually unlock the record asynchronously. |
lockQuery |
object |
The react-query query object for the lock status. |
lockMutation |
object |
The react-query mutation object for the lock mutation. |
unlockMutation |
object |
The react-query mutation object for the unlock mutation. |
useLockOnMount
This hook locks the current record on mount.
useLockOnMount
calls dataProvider.lock()
on mount and dataProvider.unlock()
on unmount to lock and unlock the record. It relies on authProvider.getIdentity()
to get the identity of the current user. It guesses the current resource
and recordId
from the context (or the route) if not provided.
Usage
Use this hook e.g. in an <Edit>
component to lock the record so that it only accepts updates from the current user.
import { EditBase, Form } from 'ra-core';
import { useLockOnMount } from '@react-admin/ra-core-ee';
const LockStatus = () => {
const { isLocked, error, isLoading } = useLockOnMount();
return (
<div>
{isLoading && <p>Locking post...</p>}
{error && (
<p>
<div>Failed to lock</div>
<div>Someone else is probably already locking it.</div>
</p>
)}
{isLocked && (
<p>
<div>Post locked</div>
<div>Only you can edit it.</div>
</p>
)}
</div>
);
};
const PostEdit = () => (
<EditBase>
<PostAside />
{/* The edit form*/}
</EditBase>
);
import { EditBase } from "ra-core";
import { useLockOnMount } from "@react-admin/ra-core-ee";
const LockStatus = () => {
const { isLocked, error, isLoading } = useLockOnMount();
return (
<div>
{isLoading && <p>Locking post...</p>}
{error && (
<p>
<div>Failed to lock</div>
<div>Someone else is probably already locking it.</div>
</p>
)}
{isLocked && (
<p>
<div>Post locked</div>
<div>Only you can edit it.</div>
</p>
)}
</div>
);
};
const PostEdit = () => (
<EditBase>
<PostAside />
{/* The edit form*/}
</EditBase>
);
Note: If users close their tab/browser when on a page with a locked record, useLockOnMount
will block the navigation and show a notification until the record is unlocked. Hence it's a good practice to give them a way to unlock the record manually, e.g. by using the doUnlock
callback returned by the hook or the <LockStatusBase>
component.
Parameters
useLockOnMount
accepts a single options parameter, with the following properties (all optional):
identity
: An identifier (string or number) corresponding to the identity of the locker (e.g.'julien'
). This could be an authentication token for instance. Falls back to the identifier of the identity returned by theAuthProvider.getIdentity()
function.resource
: The resource name (e.g.'posts'
). The hook uses theResourceContext
if not provided.id
: The record id (e.g.123
). The hook uses theRecordContext
if not provided.meta
: An object that will be forwarded to thedataProvider.lock()
calllockMutationOptions
:react-query
mutation options, used to customize the lock side-effects for instanceunlockMutationOptions
:react-query
mutation options, used to customize the unlock side-effects for instance
You can call useLockOnMount
with no parameter, and it will guess the resource and record id from the context (or the route):
const { isLocked, error, isLoading } = useLockOnMount();
const { isLocked, error, isLoading } = useLockOnMount();
Or you can provide them explicitly:
const { isLocked, error, isLoading } = useLockOnMount({
resource: 'venues',
id: 123,
identity: 'John Doe',
});
const { isLocked, error, isLoading } = useLockOnMount({
resource: "venues",
id: 123,
identity: "John Doe",
});
Tip: If the record can't be locked because another user is already locking it, you can use react-query
's retry feature to try again later:
const { isLocked, error, isLoading } = useLockOnMount({
lockMutationOptions: {
// retry every 5 seconds, until the lock is acquired
retry: true,
retryDelay: 5000,
},
});
const { isLocked, error, isLoading } = useLockOnMount({
lockMutationOptions: {
// retry every 5 seconds, until the lock is acquired
retry: true,
retryDelay: 5000,
},
});
Return value
useLockOnMount
returns an object with the following properties:
isLocked
: Whether the record is successfully locked by this hook or not.isLockedByCurrentUser
: Whether the record is locked by the current user or not.lock
: The lock data.error
: The error object if the lock attempt failed.isLocking
: Whether the lock mutation is in progress.isUnlocking
: Whether the unlock mutation is in progress.doLock
: A callback to manually lock the record.doUnlock
: A callback to manually unlock the record.doLockAsync
: A callback to manually lock the record asynchronously.doUnlockAsync
: A callback to manually unlock the record asynchronously.
useLockOnCall
Get a callback to lock a record and get a mutation state.
useLockOnCall
calls dataProvider.lock()
when the callback is called. It relies on authProvider.getIdentity()
to get the identity of the current user. It guesses the current resource
and recordId
from the context (or the route) if not provided. It releases the lock when the component unmounts by calling dataProvider.unlock()
.
Usage
Use this hook in a toolbar, to let the user lock the record manually.
import { EditBase } from 'ra-core';
import { useLockOnMount } from '@react-admin/ra-core-ee';
import { Alert, AlertTitle, Box, Button } from '@material-ui/core';
const LockStatus = () => {
const [doLock, { data, error, isLoading }] = useLockOnCall();
return (
<div>
{isLoading ? (
<div>Locking post...</div>
) : error ? (
<div>
<div>Failed to lock</div>
<div>Someone else is probably already locking it.</div>
</div>
) : data ? (
<div>
<div>Post locked</div>
<div>Only you can edit it.</div>
</div>
) : (
<button onClick={() => { doLock(); }}>
Lock post
</button>
)}
</div>
);
};
const PostEdit = () => (
<EditBase>
<PostAside />
{/* The edit form*/}
</EditBase>
);
import { EditBase } from "ra-core";
const LockStatus = () => {
const [doLock, { data, error, isLoading }] = useLockOnCall();
return (
<div>
{isLoading ? (
<div>Locking post...</div>
) : error ? (
<div>
<div>Failed to lock</div>
<div>Someone else is probably already locking it.</div>
</div>
) : data ? (
<div>
<div>Post locked</div>
<div>Only you can edit it.</div>
</div>
) : (
<button
onClick={() => {
doLock();
}}
>
Lock post
</button>
)}
</div>
);
};
const PostEdit = () => (
<EditBase>
<PostAside />
{/* The edit form*/}
</EditBase>
);
Note: If users close their tab/browser when on a page with a locked record, useLockOnCall
will block the navigation and show a notification until the record is unlocked. Hence it's a good practice to give them a way to unlock the record manually, e.g. by using the doUnlock
callback returned by the useLockCallbacks
hook or the <LockStatusBase>
component.
Parameters
useLockOnCall
accepts a single options parameter, with the following properties (all optional):
identity
: An identifier (string or number) corresponding to the identity of the locker (e.g.'julien'
). This could be an authentication token for instance. Falls back to the identifier of the identity returned by theAuthProvider.getIdentity()
function.resource
: The resource name (e.g.'posts'
). The hook uses theResourceContext
if not provided.id
: The record id (e.g.123
). The hook uses theRecordContext
if not provided.meta
: An object that will be forwarded to thedataProvider.lock()
calllockMutationOptions
:react-query
mutation options, used to customize the lock side-effects for instanceunlockMutationOptions
:react-query
mutation options, used to customize the unlock side-effects for instance
const LockButton = ({ resource, id, identity }) => {
const [doLock, lockMutation] = useLockOnCall({ resource, id, identity });
return (
<button onClick={() => {doLock();}} disabled={lockMutation.isLoading}>
Lock
</button>
);
};
const LockButton = ({ resource, id, identity }) => {
const [doLock, lockMutation] = useLockOnCall({ resource, id, identity });
return (
<button
onClick={() => {
doLock();
}}
disabled={lockMutation.isLoading}
>
Lock
</button>
);
};
useGetListLive
Alternative to useGetList
that subscribes to live updates on the record list.
import { useGetListLive } from '@react-admin/ra-core-ee';
const LatestNews = () => {
const { data, total, isLoading, error } = useGetListLive('posts', {
pagination: { page: 1, perPage: 10 },
sort: { field: 'published_at', order: 'DESC' },
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <p>ERROR</p>;
}
return (
<ul>
{data.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
};
import { useGetListLive } from "@react-admin/ra-core-ee";
const LatestNews = () => {
const { data, total, isLoading, error } = useGetListLive("posts", {
pagination: { page: 1, perPage: 10 },
sort: { field: "published_at", order: "DESC" },
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <p>ERROR</p>;
}
return (
<ul>
{data.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
};
The hook will subscribe to live updates on the list of records (topic: resource/[resource]
) and will refetch the list when a new record is created, or an existing record is updated or deleted.
See the useGetList documentation for the full list of parameters and return type.
useGetOneLive
Alternative to useGetOne() that subscribes to live updates on the record
import { useRecordContext } from 'ra-core';
import { useGetOneLive } from '@react-admin/ra-core-ee';
const UserProfile = () => {
const record = useRecordContext();
const { data, isLoading, error } = useGetOneLive('users', {
id: record.id,
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <p>ERROR</p>;
}
return <div>User {data.username}</div>;
};
import { useRecordContext } from "ra-core";
import { useGetOneLive } from "@react-admin/ra-core-ee";
const UserProfile = () => {
const record = useRecordContext();
const { data, isLoading, error } = useGetOneLive("users", {
id: record.id,
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <p>ERROR</p>;
}
return <div>User {data.username}</div>;
};
The hook will subscribe to live updates on the record (topic: resource/[resource]/[id]
) and will refetch the record when it is updated or deleted.
See the useGetOne documentation for the full list of parameters and return type.
Components
<LockStatusBase>
Use the <LockStatusBase>
component to display the lock status of the record in the nearest RecordContext
:
import React from 'react';
import { Lock, LockOpen, LoaderCircle } from 'lucide-react';
import { LockStatusBase } from '@react-admin/ra-core-ee';
export const LockStatus = () => {
return (
<LockStatusBase
{...props}
render={({
doLock,
doUnlock,
isLocking,
isPending,
isUnlocking,
lockStatus,
message,
}) => {
if (isPending) {
return null;
}
if (lockStatus === 'lockedByUser') {
return (
<button
title={message}
disabled={isUnlocking}
onClick={(
e: React.MouseEvent<HTMLButtonElement>
) => {
e.stopPropagation();
doUnlock();
}}
>
{isUnlocking ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Lock className="h-4 w-4" />
)}
</button>
);
}
if (lockStatus === 'lockedByAnotherUser') {
return (
<Lock className="h-4 w-4 text-error" />
);
}
if (lockStatus === 'unlocked') {
return (
<button
title={message}
disabled={isLocking}
onClick={(
e: React.MouseEvent<HTMLButtonElement>
) => {
e.stopPropagation();
doLock();
}}
color="warning"
>
{isLocking ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<LockOpen className="h-4 w-4" />
)}
</button>
);
}
return null;
}}
/>
);
};
import React from "react";
import { Lock, LockOpen, LoaderCircle } from "lucide-react";
import { LockStatusBase } from "@react-admin/ra-core-ee";
export const LockStatus = () => {
return (
<LockStatusBase
{...props}
render={({ doLock, doUnlock, isLocking, isPending, isUnlocking, lockStatus, message }) => {
if (isPending) {
return null;
}
if (lockStatus === "lockedByUser") {
return (
<button
title={message}
disabled={isUnlocking}
onClick={(e) => {
e.stopPropagation();
doUnlock();
}}
>
{isUnlocking ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Lock className="h-4 w-4" />
)}
</button>
);
}
if (lockStatus === "lockedByAnotherUser") {
return <Lock className="h-4 w-4 text-error" />;
}
if (lockStatus === "unlocked") {
return (
<button
title={message}
disabled={isLocking}
onClick={(e) => {
e.stopPropagation();
doLock();
}}
color="warning"
>
{isLocking ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<LockOpen className="h-4 w-4" />
)}
</button>
);
}
return null;
}}
/>
);
};
In addition to the useLockCallbacks
parameters, <LockStatusBase>
accepts a render
prop. The function passed to the render
prop will be called with the result of the useLockCallbacks
hook.
I18N
This module uses specific translations for displaying notifications. As for all translations in react-admin, it's possible to customize the messages.
To create your own translations, you can use the TypeScript types to see the structure and see which keys are overridable.
Here is an example of how to customize translations in your app:
import polyglotI18nProvider from 'ra-i18n-polyglot';
import englishMessages from 'ra-language-english';
import frenchMessages from 'ra-language-french';
import {
raRealTimeLanguageEnglish,
raRealTimeLanguageFrench,
type RaRealTimeTranslationMessages,
} from '@react-admin/ra-realtime';
import { CoreAdmin, mergeTranslations, type TranslationMessages as BaseTranslationMessages } from 'ra-core';
/* TranslationMessages extends the default translation
* Type from ra-core (BaseTranslationMessages)
* and the ra-realtime translation Type (RaRealTimeTranslationMessages)
*/
interface TranslationMessages
extends RaRealTimeTranslationMessages,
BaseTranslationMessages {}
const customEnglishMessages: TranslationMessages = mergeTranslations(
englishMessages,
raRealTimeLanguageEnglish,
{
'ra-realtime': {
notification: {
record: {
updated: 'Wow, this entry has been modified by a ghost',
deleted: 'Hey, a ghost has stolen this entry',
},
},
},
}
);
const i18nCustomProvider = polyglotI18nProvider(locale => {
if (locale === 'fr') {
return mergeTranslations(frenchMessages, raRealTimeLanguageFrench);
}
return customEnglishMessages;
}, 'en');
export const MyApp = () => (
<CoreAdmin i18nProvider={i18nCustomProvider}>
...
</CoreAdmin>
);
import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";
import frenchMessages from "ra-language-french";
import { raRealTimeLanguageEnglish, raRealTimeLanguageFrench } from "@react-admin/ra-realtime";
import { CoreAdmin, mergeTranslations } from "ra-core";
const customEnglishMessages = mergeTranslations(englishMessages, raRealTimeLanguageEnglish, {
"ra-realtime": {
notification: {
record: {
updated: "Wow, this entry has been modified by a ghost",
deleted: "Hey, a ghost has stolen this entry",
},
},
},
});
const i18nCustomProvider = polyglotI18nProvider((locale) => {
if (locale === "fr") {
return mergeTranslations(frenchMessages, raRealTimeLanguageFrench);
}
return customEnglishMessages;
}, "en");
export const MyApp = () => <CoreAdmin i18nProvider={i18nCustomProvider}>...</CoreAdmin>;
CHANGELOG
v1.1.0
2025-09-12
- Import headless hooks and functions from ra-realtime
v1.0.0
2025-09-08
- Import headless hooks from ra-rbac