Handle Effector's Events in UI-frameworks ​
Sometimes you need to do something on UI-framework layer when an Event is fired on Effector layer. For example, you may want to show a notification when a request for data is failed. In this article, we will look into a way to do it.
The problem ​
TIP
In this article, we will use React as an example of a UI-framework. However, the same principles can be applied to any other UI-framework.
Let us imagine that we have an application uses Ant Design and its notification system. It is pretty straightforward to show a notification on UI-layer
import { notification } from 'antd';
function App() {
const [api, contextHolder] = notification.useNotification();
const showNotification = () => {
api.info({
message: 'Hello, React',
description: 'Notification from UI-layer',
});
};
return (
<>
{contextHolder}
<button onClick={showNotification}>Show notification</button>
</>
);
}
But what if we want to show a notification when a request for data is failed? The whole data-flow of the application should not be exposed to the UI-layer. So, we need to find a way to handle Events on UI-layer without exposing the whole data-flow.
Let us say that we have an Event responsible for data loading failure:
// model.ts
import { createEvent } from 'effector';
const dataLoadingFailed = createEvent<{ reason: string }>();
Our application calls it every time when a request for data is failed, and we need to listen to it on UI-layer.
The solution ​
We need to bound dataLoadingFailed
and notification.useNotification
somehow.
Let us take a look on a ideal solution and a couple of not-so-good solutions.
🟢 Save notification
instance to a Store ​
The best way is saving notification
API-instance to a Store and using it thru Effect. Let us create a couple new units to do it.
// notifications.ts
import { createEvent, createStore, sample } from 'effector';
// We will use instance from this Store in the application
const $notificationApi = createStore(null);
// It has to be called every time when a new instance of notification API is created
export const notificationApiChanged = createEvent();
// Save new instance to the Store
sample({ clock: notificationApiChanged, target: $notificationApi });
Now we have to call notificationApiChanged
to save notification
API-instance to Store $notificationApi
.
import { notification } from 'antd';
import { useEffect } from 'react';
import { useUnit } from 'effector-react';
import { notificationApiChanged } from './notifications';
function App() {
// use useUnit to respect Fork API rules
const onNewApiInstance = useUnit(notificationApiChanged);
const [api, contextHolder] = notification.useNotification();
// call onNewApiInstance on every change of api
useEffect(() => {
onNewApiInstance(api);
}, [api]);
return (
<>
{contextHolder}
{/* ...the rest of the application */}
</>
);
}
After that, we have a valid Store $notificationApi
with notification
API-instance. We can use it in any place of the application. Let us create a couple Effects to work with it comfortably.
// notifications.ts
import { attach } from 'effector';
// ...
export const showWarningFx = attach({
source: $notificationApi,
effect(api, { message, description }) {
if (!api) {
throw new Error('Notification API is not ready');
}
api.warning({ message, description });
},
});
TIP
attach
is a function that allows to bind specific Store to an Effect. It means that we can use notificationApi
in showWarningFx
without passing it as a parameter. Read more in Effector's documentation.
Effect showWarningFx
can be used in any place of the application without any additional hustle.
// model.ts
import { createEvent, sample } from 'effector';
import { showWarningFx } from './notifications';
const dataLoadingFailed = createEvent<{ reason: string }>();
// Show warning when dataLoadingFailed is happened
sample({
clock: dataLoadingFailed,
fn: ({ reason }) => ({ message: reason }),
target: showWarningFx,
});
Now we have a valid solution to handle Events on UI-layer without exposing the whole data-flow.
However, if you want to know why other (maybe more obvious) solutions are not so good, you can read about them below 👇
Not-so-good solutions
🔴 Global notification
service ​
Ant Design allows using global notification instance.
// model.ts
import { createEvent, createEffect, sample } from 'effector';
import { notification } from 'antd';
const dataLoadingFailed = createEvent<{ reason: string }>();
// Create an Effect to show a notification
const showWarningFx = createEffect((params: { message: string }) => {
notification.warning(params);
});
// Execute it when dataLoadingFailed is happened
sample({
clock: dataLoadingFailed,
fn: ({ reason }) => ({ message: reason }),
target: showWarningFx,
});
In this solution it is not possible to use any Ant's settings from React Context, because it does not have access to the React at all. It means that notifications will not be styled properly and could look different from the rest of the application.
So, this is not a solution.
🔴 Just .watch
an Event in a component ​
It is possible to call .watch
-method of an Event in a component.
import { useEffect } from 'react';
import { notification } from 'antd';
import { dataLoadingFailed } from './model';
function App() {
const [api, contextHolder] = notification.useNotification();
useEffect(
() =>
dataLoadingFailed.watch(({ reason }) => {
api.warning({
message: reason,
});
}),
[api]
);
return (
<>
{contextHolder}
{/* ...the rest of the application */}
</>
);
}
In this solution we do not respect Fork API rules, it means that we could have memory leaks, problems with test environments and Storybook-like tools.
So, this is not a solution.
Summary ​
To bind some UI-framework specific API to Effector's data-flow we need to follow these steps: