Live / Realtime
Refine lets you add Realtime support to your app via the liveProvider
prop for <Refine />
. It can be used to update and show data in Realtime throughout your app. Refine remains agnostic in its API to allow different solutions(Ably, Socket.IO, Mercure, supabase, etc.) to be integrated.
Refer to the Live Provider documentation for detailed information. →
We will be using Ably in this guide to provide Realtime features.
Installation
We need to install the Ably live provider package from Refine.
- npm
- pnpm
- yarn
npm i @refinedev/ably
pnpm add @refinedev/ably
yarn add @refinedev/ably
To make this example more visual, we used the @refinedev/antd
package. If you are using Refine headless, you need to provide the components, hooks, or helpers imported from the @refinedev/antd
package.
Setup
Since we will need apiKey
from Ably, you must first register and get the key from Ably.
The app will have one resource: posts with CRUD pages(list, create, edit, and show) similar to base example.
You can also refer to CodeSandbox to see the final state of the app →
Adding liveProvider
Firstly we create a Ably client for @refinedev/ably
live provider.
import { Ably } from "@refinedev/ably";
export const ablyClient = new Ably.Realtime("your-api-key");
Then pass liveProvider
from @refinedev/ably
to <Refine>
.
import { Refine } from "@refinedev/core";
import {
ThemedLayoutV2,
useNotificationProvider,
ErrorComponent,
} from "@refinedev/antd";
import dataProvider from "@refinedev/simple-rest";
import routerProvider, { NavigateToResource } from "@refinedev/react-router-v6";
import { BrowserRouter, Routes, Route, Outlet } from "react-router-dom";
import { ConfigProvider } from "antd";
import "@refinedev/antd/dist/reset.css";
import { liveProvider } from "@refinedev/ably";
import { ablyClient } from "utility/ablyClient";
import { PostList, PostCreate, PostEdit, PostShow } from "pages/posts";
const App: React.FC = () => {
return (
<BrowserRouter>
<ConfigProvider theme={RefineThemes.Blue}>
<Refine
routerProvider={routerProvider}
dataProvider={dataProvider("https://api.fake-rest.refine.dev")}
notificationProvider={useNotificationProvider}
liveProvider={liveProvider(ablyClient)}
options={{ liveMode: "auto" }}
resources={[
{
name: "posts",
list: "/posts",
show: "/posts/show/:id",
create: "/posts/create",
edit: "/posts/edit/:id",
meta: {
canDelete: true,
},
},
]}
>
<Routes>
<Route
element={
<ThemedLayoutV2>
<Outlet />
</ThemedLayoutV2>
}
>
<Route index element={<NavigateToResource />} />
<Route path="/posts" element={<PostList />} />
<Route path="/posts/create" element={<PostCreate />} />
<Route path="/posts/show/:id" element={<PostShow />} />
<Route path="/posts/edit/:id" element={<PostEdit />} />
</Route>
<Route path="*" element={<ErrorComponent />} />
</Routes>
</Refine>
</ConfigProvider>
</BrowserRouter>
);
};
export default App;
For live features to work automatically we added liveMode: "auto"
in the options
prop.
Refer to the Live Provider documentation for detailed information. →
Configuring liveMode
We may not want to make Realtime changes instantly in some cases. In these cases, we can use manual
mode to prevent the data from changing instantly. Then we can handle the event manually.
For example in an edit page for a record, It would be better to handle Realtime data manually to prevent synchronization problems caused by multiple editing sources. We would not want the data changing while we are trying to edit a record.
We will be alerted about changes in an alert box on top of the form instead of changing the data instantly.
// ...
export const PostEdit: React.FC = () => {
const [deprecated, setDeprecated] = useState<
"deleted" | "updated" | undefined
>();
const { formProps, saveButtonProps, queryResult } = useForm<IPost>({
liveMode: "manual",
onLiveEvent: (event) => {
if (event.type === "deleted" || event.type === "updated") {
setDeprecated(event.type);
}
},
});
const handleRefresh = () => {
queryResult?.refetch();
setDeprecated(undefined);
};
// ...
return (
<Edit /* ... */>
{deprecated === "deleted" && (
<Alert
message="This post is deleted."
type="warning"
style={{ marginBottom: 20 }}
action={<ListButton size="small" />}
/>
)}
{deprecated === "updated" && (
<Alert
message="This post is updated. Refresh to see changes."
type="warning"
style={{ marginBottom: 20 }}
action={<RefreshButton size="small" onClick={handleRefresh} />}
/>
)}
<Form {...formProps} layout="vertical">
// ....
</Form>
</Edit>
);
};
We can also implement a similar thing on the show page.
Refer to the CodeSandbox example for detailed information. →
Custom Subscriptions
You can subscribe to events emitted within Refine in any place in your app with useSubscription
.
For example, we can subscribe to create event for posts resource and we can show a badge for the number of events in the sider menu.
Firstly, let's implement a custom sider like in this example.
Custom Sider Menu
import React, { useState } from "react";
import {
ITreeMenu,
CanAccess,
useIsExistAuthentication,
useTranslate,
useLogout,
useMenu,
useWarnAboutChange,
} from "@refinedev/core";
import { Link } from "react-router-dom";
import { Sider, ThemedTitleV2 } from "@refinedev/antd";
import { Layout as AntdLayout, Menu, Grid, theme, Button } from "antd";
import {
LogoutOutlined,
UnorderedListOutlined,
RightOutlined,
LeftOutlined,
} from "@ant-design/icons";
import { antLayoutSider, antLayoutSiderMobile } from "./styles";
const { useToken } = theme;
export const CustomSider: typeof Sider = ({ render }) => {
const { token } = useToken();
const [collapsed, setCollapsed] = useState<boolean>(false);
const isExistAuthentication = useIsExistAuthentication();
const { warnWhen, setWarnWhen } = useWarnAboutChange();
const { mutate: mutateLogout } = useLogout();
const translate = useTranslate();
const { menuItems, selectedKey, defaultOpenKeys } = useMenu();
const { SubMenu } = Menu;
const breakpoint = Grid.useBreakpoint();
const isMobile =
typeof breakpoint.lg === "undefined" ? false : !breakpoint.lg;
const renderTreeView = (tree: ITreeMenu[], selectedKey: string) => {
return tree.map((item: ITreeMenu) => {
const { name, children, meta, key, list } = item;
const icon = meta?.icon;
const label = meta?.label ?? name;
const parent = meta?.parent;
const route =
typeof list === "string"
? list
: typeof list !== "function"
? list?.path
: key;
if (children.length > 0) {
return (
<SubMenu
key={route}
icon={icon ?? <UnorderedListOutlined />}
title={label}
>
{renderTreeView(children, selectedKey)}
</SubMenu>
);
}
const isSelected = route === selectedKey;
const isRoute = !(parent !== undefined && children.length === 0);
return (
<CanAccess
key={route}
resource={name.toLowerCase()}
action="list"
params={{ resource: item }}
>
<Menu.Item
key={route}
style={{
textTransform: "capitalize",
}}
icon={icon ?? (isRoute && <UnorderedListOutlined />)}
>
{route ? <Link to={route || "/"}>{label}</Link> : label}
{!collapsed && isSelected && (
<div className="ant-menu-tree-arrow" />
)}
</Menu.Item>
</CanAccess>
);
});
};
const handleLogout = () => {
if (warnWhen) {
const confirm = window.confirm(
translate(
"warnWhenUnsavedChanges",
"Are you sure you want to leave? You have unsaved changes.",
),
);
if (confirm) {
setWarnWhen(false);
mutateLogout();
}
} else {
mutateLogout();
}
};
const logout = isExistAuthentication && (
<Menu.Item key="logout" onClick={handleLogout} icon={<LogoutOutlined />}>
{translate("buttons.logout", "Logout")}
</Menu.Item>
);
const items = renderTreeView(menuItems, selectedKey);
const renderSider = () => {
if (render) {
return render({
dashboard: null,
items,
logout,
collapsed,
});
}
return (
<>
{items}
{logout}
</>
);
};
const siderStyle = isMobile ? antLayoutSiderMobile : antLayoutSider;
return (
<AntdLayout.Sider
collapsible
collapsedWidth={isMobile ? 0 : 80}
collapsed={collapsed}
breakpoint="lg"
onCollapse={(collapsed: boolean): void => setCollapsed(collapsed)}
style={{
...siderStyle,
backgroundColor: token.colorBgContainer,
borderRight: `1px solid ${token.colorBgElevated}`,
}}
trigger={
!isMobile && (
<Button
type="text"
style={{
borderRadius: 0,
height: "100%",
width: "100%",
backgroundColor: token.colorBgElevated,
}}
>
{collapsed ? (
<RightOutlined
style={{
color: token.colorPrimary,
}}
/>
) : (
<LeftOutlined
style={{
color: token.colorPrimary,
}}
/>
)}
</Button>
)
}
>
<div
style={{
width: collapsed ? "80px" : "200px",
padding: collapsed ? "0" : "0 16px",
display: "flex",
justifyContent: collapsed ? "center" : "flex-start",
alignItems: "center",
height: "64px",
backgroundColor: token.colorBgElevated,
fontSize: "14px",
}}
>
<ThemedTitleV2 collapsed={collapsed} />
</div>
<Menu
defaultOpenKeys={defaultOpenKeys}
selectedKeys={[selectedKey]}
mode="inline"
style={{
marginTop: "8px",
border: "none",
}}
onClick={() => {
if (!breakpoint.lg) {
setCollapsed(true);
}
}}
>
{renderSider()}
</Menu>
</AntdLayout.Sider>
);
};
Now, let's add a badge for the number of create and update events for posts menu items.
Show Custom Sider Menu with subscription bagde code
import React, { useState } from "react";
import {
ITreeMenu,
CanAccess,
useIsExistAuthentication,
useTranslate,
useLogout,
useMenu,
useWarnAboutChange,
useSubscription,
} from "@refinedev/core";
import { Link } from "react-router-dom";
import { Sider, ThemedTitleV2 } from "@refinedev/antd";
import { Layout as AntdLayout, Menu, Grid, theme, Button, Badge } from "antd";
import {
LogoutOutlined,
UnorderedListOutlined,
RightOutlined,
LeftOutlined,
} from "@ant-design/icons";
import { antLayoutSider, antLayoutSiderMobile } from "./styles";
const { useToken } = theme;
export const CustomSider: typeof Sider = ({ render }) => {
const { token } = useToken();
const [collapsed, setCollapsed] = useState<boolean>(false);
const isExistAuthentication = useIsExistAuthentication();
const { warnWhen, setWarnWhen } = useWarnAboutChange();
const { mutate: mutateLogout } = useLogout();
const translate = useTranslate();
const { menuItems, selectedKey, defaultOpenKeys } = useMenu();
const { SubMenu } = Menu;
const [subscriptionCount, setSubscriptionCount] = useState(0);
const breakpoint = Grid.useBreakpoint();
const isMobile =
typeof breakpoint.lg === "undefined" ? false : !breakpoint.lg;
useSubscription({
channel: "resources/posts",
types: ["created", "updated"],
onLiveEvent: () => setSubscriptionCount((prev) => prev + 1),
});
const renderTreeView = (tree: ITreeMenu[], selectedKey?: string) => {
return tree.map((item: ITreeMenu) => {
const { name, children, meta, key, list } = item;
const icon = meta?.icon;
const label = meta?.label ?? name;
const parent = meta?.parent;
const route =
typeof list === "string"
? list
: typeof list !== "function"
? list?.path
: key;
if (children.length > 0) {
return (
<SubMenu
key={key}
icon={icon ?? <UnorderedListOutlined />}
title={label}
>
{renderTreeView(children, selectedKey)}
</SubMenu>
);
}
const isSelected = route === selectedKey;
const isRoute = !(parent !== undefined && children.length === 0);
return (
<CanAccess
key={key}
resource={name.toLowerCase()}
action="list"
params={{ resource: item }}
>
<Menu.Item
key={route}
style={{
textTransform: "capitalize",
}}
icon={icon ?? (isRoute && <UnorderedListOutlined />)}
>
{route ? <Link to={route || "/"}>{label}</Link> : label}
{route && (
<>
{label.toLowerCase() === "posts" && (
<Badge
size="small"
count={subscriptionCount}
offset={[2, -15]}
/>
)}
</>
)}
{!collapsed && isSelected && (
<div className="ant-menu-tree-arrow" />
)}
</Menu.Item>
</CanAccess>
);
});
};
const handleLogout = () => {
if (warnWhen) {
const confirm = window.confirm(
translate(
"warnWhenUnsavedChanges",
"Are you sure you want to leave? You have unsaved changes.",
),
);
if (confirm) {
setWarnWhen(false);
mutateLogout();
}
} else {
mutateLogout();
}
};
const logout = isExistAuthentication && (
<Menu.Item key="logout" onClick={handleLogout} icon={<LogoutOutlined />}>
{translate("buttons.logout", "Logout")}
</Menu.Item>
);
const items = renderTreeView(menuItems, selectedKey);
const renderSider = () => {
if (render) {
return render({
dashboard: null,
items,
logout,
collapsed,
});
}
return (
<>
{items}
{logout}
</>
);
};
const siderStyle = isMobile ? antLayoutSiderMobile : antLayoutSider;
return (
<AntdLayout.Sider
collapsible
collapsedWidth={isMobile ? 0 : 80}
collapsed={collapsed}
breakpoint="lg"
onCollapse={(collapsed: boolean): void => setCollapsed(collapsed)}
style={{
...siderStyle,
backgroundColor: token.colorBgContainer,
borderRight: `1px solid ${token.colorBgElevated}`,
}}
trigger={
!isMobile && (
<Button
type="text"
style={{
borderRadius: 0,
height: "100%",
width: "100%",
backgroundColor: token.colorBgElevated,
}}
>
{collapsed ? (
<RightOutlined
style={{
color: token.colorPrimary,
}}
/>
) : (
<LeftOutlined
style={{
color: token.colorPrimary,
}}
/>
)}
</Button>
)
}
>
<div
style={{
width: collapsed ? "80px" : "200px",
padding: collapsed ? "0" : "0 16px",
display: "flex",
justifyContent: collapsed ? "center" : "flex-start",
alignItems: "center",
height: "64px",
backgroundColor: token.colorBgElevated,
fontSize: "14px",
}}
>
<ThemedTitleV2 collapsed={collapsed} />
</div>
<Menu
defaultOpenKeys={defaultOpenKeys}
selectedKeys={[selectedKey]}
mode="inline"
style={{
marginTop: "8px",
border: "none",
}}
onClick={({ key }) => {
if (!breakpoint.lg) {
setCollapsed(true);
}
if (key === "/posts") {
setSubscriptionCount(0);
}
}}
>
{renderSider()}
</Menu>
</AntdLayout.Sider>
);
};
You can subscribe to specific ids
with params
. For example, you can subscribe to deleted and updated events from posts resource with id 1
and 2
.
useSubscription({
channel: "resources/posts",
type: ["deleted", "updated"],
params: {
ids: ["1", "2"],
},
onLiveEvent: () => setSubscriptionCount((prev) => prev + 1),
});
Example
npm create refine-app@latest -- --example live-provider-ably